diff --git a/packages/editor-app/package.json b/packages/editor-app/package.json index 7e7a6627..30a5d074 100644 --- a/packages/editor-app/package.json +++ b/packages/editor-app/package.json @@ -9,20 +9,26 @@ "build": "npm run build:sdk && tsc && vite build", "build:watch": "vite build --watch", "tauri": "tauri", - "tauri:dev": "npm run build:sdk && tauri dev", + "copy-modules": "node ../../scripts/copy-engine-modules.mjs", + "tauri:dev": "npm run build:sdk && npm run copy-modules && tauri dev", "bundle:runtime": "node scripts/bundle-runtime.mjs", - "tauri:build": "npm run build:sdk && npm run bundle:runtime && tauri build", + "tauri:build": "npm run build:sdk && npm run copy-modules && npm run bundle:runtime && tauri build", "version": "node scripts/sync-version.js && git add src-tauri/tauri.conf.json" }, "dependencies": { "@esengine/asset-system": "workspace:*", + "@esengine/asset-system-editor": "workspace:*", "@esengine/behavior-tree": "workspace:*", + "@esengine/material-system": "workspace:*", + "@esengine/material-editor": "workspace:*", "@esengine/behavior-tree-editor": "workspace:*", "@esengine/blueprint": "workspace:*", "@esengine/blueprint-editor": "workspace:*", "@esengine/editor-runtime": "workspace:*", "@esengine/engine-core": "workspace:*", "@esengine/sprite": "workspace:*", + "@esengine/sprite-editor": "workspace:*", + "@esengine/shader-editor": "workspace:*", "@esengine/camera": "workspace:*", "@esengine/audio": "workspace:*", "@esengine/physics-rapier2d": "workspace:*", diff --git a/packages/editor-app/src-tauri/Cargo.lock b/packages/editor-app/src-tauri/Cargo.lock index 0674cada..ccfad91a 100644 --- a/packages/editor-app/src-tauri/Cargo.lock +++ b/packages/editor-app/src-tauri/Cargo.lock @@ -1100,6 +1100,8 @@ dependencies = [ "futures-util", "glob", "image", + "notify", + "notify-debouncer-mini", "once_cell", "qrcode", "serde", @@ -1379,6 +1381,15 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fsevent-sys" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2" +dependencies = [ + "libc", +] + [[package]] name = "futf" version = "0.1.5" @@ -2205,6 +2216,26 @@ dependencies = [ "cfb", ] +[[package]] +name = "inotify" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdd168d97690d0b8c412d6b6c10360277f4d7ee495c5d0d5d5fe0854923255cc" +dependencies = [ + "bitflags 1.3.2", + "inotify-sys", + "libc", +] + +[[package]] +name = "inotify-sys" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb" +dependencies = [ + "libc", +] + [[package]] name = "inout" version = "0.1.4" @@ -2214,6 +2245,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "instant" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222" +dependencies = [ + "cfg-if", +] + [[package]] name = "interpolate_name" version = "0.2.4" @@ -2379,6 +2419,26 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "kqueue" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eac30106d7dce88daf4a3fcb4879ea939476d5074a9b7ddd0fb97fa4bed5596a" +dependencies = [ + "kqueue-sys", + "libc", +] + +[[package]] +name = "kqueue-sys" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed9625ffda8729b85e45cf04090035ac368927b8cebc34898e7c120f52e4838b" +dependencies = [ + "bitflags 1.3.2", + "libc", +] + [[package]] name = "kuchikiki" version = "0.8.8-speedreader" @@ -2603,6 +2663,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78bed444cc8a2160f01cbcf811ef18cac863ad68ae8ca62092e8db51d51c761c" dependencies = [ "libc", + "log", "wasi 0.11.1+wasi-snapshot-preview1", "windows-sys 0.59.0", ] @@ -2708,6 +2769,46 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8" +[[package]] +name = "notify" +version = "7.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c533b4c39709f9ba5005d8002048266593c1cfaf3c5f0739d5b8ab0c6c504009" +dependencies = [ + "bitflags 2.9.4", + "filetime", + "fsevent-sys", + "inotify", + "kqueue", + "libc", + "log", + "mio", + "notify-types", + "walkdir", + "windows-sys 0.52.0", +] + +[[package]] +name = "notify-debouncer-mini" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aaa5a66d07ed97dce782be94dcf5ab4d1b457f4243f7566c7557f15cabc8c799" +dependencies = [ + "log", + "notify", + "notify-types", + "tempfile", +] + +[[package]] +name = "notify-types" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "585d3cb5e12e01aed9e8a1f70d5c6b5e86fe2a6e48fc8cd0b3e0b8df6f6eb174" +dependencies = [ + "instant", +] + [[package]] name = "num-bigint" version = "0.4.6" diff --git a/packages/editor-app/src-tauri/Cargo.toml b/packages/editor-app/src-tauri/Cargo.toml index fc3c7b58..07881cc5 100644 --- a/packages/editor-app/src-tauri/Cargo.toml +++ b/packages/editor-app/src-tauri/Cargo.toml @@ -34,6 +34,8 @@ once_cell = "1.19" urlencoding = "2.1" qrcode = "0.14" image = "0.25" +notify = "7.0" +notify-debouncer-mini = "0.5" [profile.dev] incremental = true diff --git a/packages/editor-app/src-tauri/src/commands/build.rs b/packages/editor-app/src-tauri/src/commands/build.rs new file mode 100644 index 00000000..a9adbbf2 --- /dev/null +++ b/packages/editor-app/src-tauri/src/commands/build.rs @@ -0,0 +1,455 @@ +//! Build related commands. +//! 构建相关命令。 +//! +//! Provides file operations and compilation for build pipelines. +//! 为构建管线提供文件操作和编译功能。 + +use serde::{Deserialize, Serialize}; +use std::fs; +use std::path::Path; +use std::process::Command; + +/// Build progress event. +/// 构建进度事件。 +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct BuildProgressEvent { + /// Progress percentage (0-100) | 进度百分比 + pub progress: u32, + /// Current step message | 当前步骤消息 + pub message: String, + /// Current step index | 当前步骤索引 + pub current_step: u32, + /// Total steps | 总步骤数 + pub total_steps: u32, +} + +/// Clean and recreate output directory. +/// 清理并重建输出目录。 +#[tauri::command] +pub async fn prepare_build_directory(output_path: String) -> Result<(), String> { + let path = Path::new(&output_path); + + // Remove existing directory if exists | 如果存在则删除现有目录 + if path.exists() { + fs::remove_dir_all(path) + .map_err(|e| format!("Failed to clean output directory | 清理输出目录失败: {}", e))?; + } + + // Create fresh directory | 创建新目录 + fs::create_dir_all(path) + .map_err(|e| format!("Failed to create output directory | 创建输出目录失败: {}", e))?; + + Ok(()) +} + +/// Copy directory recursively. +/// 递归复制目录。 +#[tauri::command] +pub async fn copy_directory( + src: String, + dst: String, + patterns: Option>, +) -> Result { + let src_path = Path::new(&src); + let dst_path = Path::new(&dst); + + if !src_path.exists() { + return Err(format!("Source directory does not exist | 源目录不存在: {}", src)); + } + + // Create destination directory | 创建目标目录 + fs::create_dir_all(dst_path) + .map_err(|e| format!("Failed to create destination directory | 创建目标目录失败: {}", e))?; + + let mut copied_count = 0u32; + + // Recursively copy | 递归复制 + copy_dir_recursive(src_path, dst_path, &patterns, &mut copied_count)?; + + Ok(copied_count) +} + +/// Helper function to copy directory recursively. +/// 递归复制目录的辅助函数。 +fn copy_dir_recursive( + src: &Path, + dst: &Path, + patterns: &Option>, + count: &mut u32, +) -> Result<(), String> { + for entry in fs::read_dir(src) + .map_err(|e| format!("Failed to read directory | 读取目录失败: {}", e))? + { + let entry = entry.map_err(|e| format!("Failed to read entry | 读取条目失败: {}", e))?; + let src_path = entry.path(); + let file_name = entry.file_name(); + let dst_path = dst.join(&file_name); + + if src_path.is_dir() { + // Skip hidden directories | 跳过隐藏目录 + if file_name.to_string_lossy().starts_with('.') { + continue; + } + + fs::create_dir_all(&dst_path) + .map_err(|e| format!("Failed to create directory | 创建目录失败: {}", e))?; + copy_dir_recursive(&src_path, &dst_path, patterns, count)?; + } else { + // Check if file matches patterns | 检查文件是否匹配模式 + if let Some(ref pats) = patterns { + let file_name_str = file_name.to_string_lossy(); + let matches = pats.iter().any(|p| { + if p.starts_with("*.") { + let ext = &p[2..]; + file_name_str.ends_with(&format!(".{}", ext)) + } else { + file_name_str.contains(p) + } + }); + + if !matches { + continue; + } + } + + fs::copy(&src_path, &dst_path) + .map_err(|e| format!("Failed to copy file | 复制文件失败: {} -> {}: {}", + src_path.display(), dst_path.display(), e))?; + *count += 1; + } + } + + Ok(()) +} + +/// Bundle options for esbuild. +/// esbuild 打包选项。 +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BundleOptions { + /// Entry files | 入口文件 + pub entry_points: Vec, + /// Output directory | 输出目录 + pub output_dir: String, + /// Output format (esm or iife) | 输出格式 + pub format: String, + /// Bundle name | 打包名称 + pub bundle_name: String, + /// Whether to minify | 是否压缩 + pub minify: bool, + /// Whether to generate source map | 是否生成 source map + pub source_map: bool, + /// External dependencies | 外部依赖 + pub external: Vec, + /// Project root for resolving imports | 项目根目录 + pub project_root: String, + /// Define replacements | 宏定义替换 + pub define: Option>, +} + +/// Bundle result. +/// 打包结果。 +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct BundleResult { + /// Whether bundling succeeded | 是否打包成功 + pub success: bool, + /// Output file path | 输出文件路径 + pub output_file: Option, + /// Output file size in bytes | 输出文件大小(字节) + pub output_size: Option, + /// Error message if failed | 失败时的错误信息 + pub error: Option, + /// Warnings | 警告 + pub warnings: Vec, +} + +/// Bundle JavaScript/TypeScript files using esbuild. +/// 使用 esbuild 打包 JavaScript/TypeScript 文件。 +#[tauri::command] +pub async fn bundle_scripts(options: BundleOptions) -> Result { + let esbuild_path = find_esbuild(&options.project_root)?; + + // Build output file path | 构建输出文件路径 + let output_file = Path::new(&options.output_dir) + .join(&options.bundle_name) + .with_extension("js"); + + // Ensure output directory exists | 确保输出目录存在 + if let Some(parent) = output_file.parent() { + fs::create_dir_all(parent) + .map_err(|e| format!("Failed to create output directory | 创建输出目录失败: {}", e))?; + } + + // Build esbuild arguments | 构建 esbuild 参数 + let mut args: Vec = options.entry_points.clone(); + + args.push("--bundle".to_string()); + args.push(format!("--outfile={}", output_file.display())); + args.push(format!("--format={}", options.format)); + args.push("--platform=browser".to_string()); + args.push("--target=es2020".to_string()); + + if options.source_map { + args.push("--sourcemap".to_string()); + } + + if options.minify { + args.push("--minify".to_string()); + } + + for external in &options.external { + args.push(format!("--external:{}", external)); + } + + // Add define replacements | 添加宏定义替换 + if let Some(ref defines) = options.define { + for (key, value) in defines { + args.push(format!("--define:{}={}", key, value)); + } + } + + // Run esbuild | 运行 esbuild + let output = Command::new(&esbuild_path) + .args(&args) + .current_dir(&options.project_root) + .output() + .map_err(|e| format!("Failed to run esbuild | 运行 esbuild 失败: {}", e))?; + + if output.status.success() { + // Get output file size | 获取输出文件大小 + let output_size = fs::metadata(&output_file) + .map(|m| m.len()) + .ok(); + + // Parse warnings from stderr | 从 stderr 解析警告 + let stderr = String::from_utf8_lossy(&output.stderr); + let warnings: Vec = stderr + .lines() + .filter(|l| l.contains("warning")) + .map(|l| l.to_string()) + .collect(); + + Ok(BundleResult { + success: true, + output_file: Some(output_file.to_string_lossy().to_string()), + output_size, + error: None, + warnings, + }) + } else { + let stderr = String::from_utf8_lossy(&output.stderr); + + Ok(BundleResult { + success: false, + output_file: None, + output_size: None, + error: Some(stderr.to_string()), + warnings: vec![], + }) + } +} + +/// Generate HTML file from template. +/// 从模板生成 HTML 文件。 +#[tauri::command] +pub async fn generate_html( + output_path: String, + title: String, + scripts: Vec, + body_content: Option, +) -> Result<(), String> { + let scripts_html: String = scripts + .iter() + .map(|s| format!(r#" "#, s)) + .collect::>() + .join("\n"); + + let body = body_content.unwrap_or_else(|| { + r#" "#.to_string() + }); + + let html = format!( + r#" + + + + + {} + + + +{} +{} + +"#, + title, body, scripts_html + ); + + // Ensure parent directory exists | 确保父目录存在 + let path = Path::new(&output_path); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .map_err(|e| format!("Failed to create directory | 创建目录失败: {}", e))?; + } + + fs::write(&output_path, html) + .map_err(|e| format!("Failed to write HTML file | 写入 HTML 文件失败: {}", e))?; + + Ok(()) +} + +/// Get file size. +/// 获取文件大小。 +#[tauri::command] +pub async fn get_file_size(file_path: String) -> Result { + fs::metadata(&file_path) + .map(|m| m.len()) + .map_err(|e| format!("Failed to get file size | 获取文件大小失败: {}", e)) +} + +/// Get directory size recursively. +/// 递归获取目录大小。 +#[tauri::command] +pub async fn get_directory_size(dir_path: String) -> Result { + let path = Path::new(&dir_path); + if !path.exists() { + return Err(format!("Directory does not exist | 目录不存在: {}", dir_path)); + } + + calculate_dir_size(path) +} + +/// Helper to calculate directory size. +/// 计算目录大小的辅助函数。 +fn calculate_dir_size(path: &Path) -> Result { + let mut total_size = 0u64; + + for entry in fs::read_dir(path) + .map_err(|e| format!("Failed to read directory | 读取目录失败: {}", e))? + { + let entry = entry.map_err(|e| format!("Failed to read entry | 读取条目失败: {}", e))?; + let entry_path = entry.path(); + + if entry_path.is_dir() { + total_size += calculate_dir_size(&entry_path)?; + } else { + total_size += fs::metadata(&entry_path) + .map(|m| m.len()) + .unwrap_or(0); + } + } + + Ok(total_size) +} + +/// Find esbuild executable. +/// 查找 esbuild 可执行文件。 +fn find_esbuild(project_root: &str) -> Result { + let project_path = Path::new(project_root); + + // Try local node_modules first | 首先尝试本地 node_modules + let local_esbuild = if cfg!(windows) { + project_path.join("node_modules/.bin/esbuild.cmd") + } else { + project_path.join("node_modules/.bin/esbuild") + }; + + if local_esbuild.exists() { + return Ok(local_esbuild.to_string_lossy().to_string()); + } + + // Try global esbuild | 尝试全局 esbuild + let global_esbuild = if cfg!(windows) { "esbuild.cmd" } else { "esbuild" }; + + let check = Command::new(global_esbuild) + .arg("--version") + .output(); + + match check { + Ok(output) if output.status.success() => Ok(global_esbuild.to_string()), + _ => Err("esbuild not found | 未找到 esbuild".to_string()) + } +} + +/// Write JSON file. +/// 写入 JSON 文件。 +#[tauri::command] +pub async fn write_json_file(file_path: String, content: String) -> Result<(), String> { + let path = Path::new(&file_path); + + // Ensure parent directory exists | 确保父目录存在 + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .map_err(|e| format!("Failed to create directory | 创建目录失败: {}", e))?; + } + + fs::write(&file_path, content) + .map_err(|e| format!("Failed to write JSON file | 写入 JSON 文件失败: {}", e))?; + + Ok(()) +} + +/// List files in directory with extension filter. +/// 列出目录中指定扩展名的文件。 +#[tauri::command] +pub async fn list_files_by_extension( + dir_path: String, + extensions: Vec, + recursive: bool, +) -> Result, String> { + let path = Path::new(&dir_path); + if !path.exists() { + return Ok(vec![]); + } + + let mut files = Vec::new(); + list_files_recursive(path, &extensions, recursive, &mut files)?; + + Ok(files) +} + +/// Helper to list files recursively. +/// 递归列出文件的辅助函数。 +fn list_files_recursive( + path: &Path, + extensions: &[String], + recursive: bool, + files: &mut Vec, +) -> Result<(), String> { + for entry in fs::read_dir(path) + .map_err(|e| format!("Failed to read directory | 读取目录失败: {}", e))? + { + let entry = entry.map_err(|e| format!("Failed to read entry | 读取条目失败: {}", e))?; + let entry_path = entry.path(); + + if entry_path.is_dir() { + if recursive { + list_files_recursive(&entry_path, extensions, recursive, files)?; + } + } else if let Some(ext) = entry_path.extension() { + let ext_str = ext.to_string_lossy().to_lowercase(); + if extensions.iter().any(|e| e.to_lowercase() == ext_str) { + files.push(entry_path.to_string_lossy().to_string()); + } + } + } + + Ok(()) +} + +/// Read binary file and return as base64. +/// 读取二进制文件并返回 base64 编码。 +#[tauri::command] +pub async fn read_binary_file_as_base64(path: String) -> Result { + use base64::{Engine as _, engine::general_purpose::STANDARD}; + + let bytes = fs::read(&path) + .map_err(|e| format!("Failed to read binary file | 读取二进制文件失败: {}", e))?; + + Ok(STANDARD.encode(&bytes)) +} diff --git a/packages/editor-app/src-tauri/src/commands/compiler.rs b/packages/editor-app/src-tauri/src/commands/compiler.rs new file mode 100644 index 00000000..bdaa2a22 --- /dev/null +++ b/packages/editor-app/src-tauri/src/commands/compiler.rs @@ -0,0 +1,389 @@ +//! User code compilation commands. +//! 用户代码编译命令。 +//! +//! Provides TypeScript compilation using esbuild for user scripts. +//! 使用 esbuild 为用户脚本提供 TypeScript 编译。 + +use notify::{Config, RecommendedWatcher, RecursiveMode, Watcher, Event, EventKind}; +use serde::{Deserialize, Serialize}; +use std::path::Path; +use std::process::Command; +use std::sync::mpsc::channel; +use std::time::Duration; +use tauri::{command, AppHandle, Emitter, State}; +use crate::state::ScriptWatcherState; + +/// Compilation options. +/// 编译选项。 +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CompileOptions { + /// Entry file path | 入口文件路径 + pub entry_path: String, + /// Output file path | 输出文件路径 + pub output_path: String, + /// Output format (esm or iife) | 输出格式 + pub format: String, + /// Whether to generate source map | 是否生成 source map + pub source_map: bool, + /// Whether to minify | 是否压缩 + pub minify: bool, + /// External dependencies | 外部依赖 + pub external: Vec, + /// Project root for resolving imports | 项目根目录用于解析导入 + pub project_root: String, +} + +/// Compilation error. +/// 编译错误。 +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CompileError { + /// Error message | 错误信息 + pub message: String, + /// File path | 文件路径 + pub file: Option, + /// Line number | 行号 + pub line: Option, + /// Column number | 列号 + pub column: Option, +} + +/// Compilation result. +/// 编译结果。 +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CompileResult { + /// Whether compilation succeeded | 是否编译成功 + pub success: bool, + /// Compilation errors | 编译错误 + pub errors: Vec, + /// Output file path (if successful) | 输出文件路径(如果成功) + pub output_path: Option, +} + +/// File change event sent to frontend. +/// 发送到前端的文件变更事件。 +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct FileChangeEvent { + /// Type of change: "create", "modify", "remove" | 变更类型 + pub change_type: String, + /// File paths that changed | 发生变更的文件路径 + pub paths: Vec, +} + +/// Compile TypeScript using esbuild. +/// 使用 esbuild 编译 TypeScript。 +/// +/// # Arguments | 参数 +/// * `options` - Compilation options | 编译选项 +/// +/// # Returns | 返回 +/// Compilation result | 编译结果 +#[command] +pub async fn compile_typescript(options: CompileOptions) -> Result { + // Check if esbuild is available | 检查 esbuild 是否可用 + let esbuild_path = find_esbuild(&options.project_root)?; + + // Build esbuild arguments | 构建 esbuild 参数 + let mut args = vec![ + options.entry_path.clone(), + "--bundle".to_string(), + format!("--outfile={}", options.output_path), + format!("--format={}", options.format), + "--platform=browser".to_string(), + "--target=es2020".to_string(), + ]; + + // Add source map option | 添加 source map 选项 + if options.source_map { + args.push("--sourcemap".to_string()); + } + + // Add minify option | 添加压缩选项 + if options.minify { + args.push("--minify".to_string()); + } + + // Add external dependencies | 添加外部依赖 + for external in &options.external { + args.push(format!("--external:{}", external)); + } + + // Run esbuild | 运行 esbuild + let output = Command::new(&esbuild_path) + .args(&args) + .current_dir(&options.project_root) + .output() + .map_err(|e| format!("Failed to run esbuild | 运行 esbuild 失败: {}", e))?; + + if output.status.success() { + Ok(CompileResult { + success: true, + errors: vec![], + output_path: Some(options.output_path), + }) + } else { + let stderr = String::from_utf8_lossy(&output.stderr); + let errors = parse_esbuild_errors(&stderr); + + Ok(CompileResult { + success: false, + errors, + output_path: None, + }) + } +} + +/// Watch for file changes in scripts directory. +/// 监视脚本目录中的文件变更。 +/// +/// Emits "user-code:file-changed" events when files change. +/// 当文件发生变更时触发 "user-code:file-changed" 事件。 +#[command] +pub async fn watch_scripts( + app: AppHandle, + watcher_state: State<'_, ScriptWatcherState>, + project_path: String, + scripts_dir: String, +) -> Result<(), String> { + let watch_path = Path::new(&project_path).join(&scripts_dir); + + if !watch_path.exists() { + return Err(format!( + "Scripts directory does not exist | 脚本目录不存在: {}", + watch_path.display() + )); + } + + // Check if already watching this project | 检查是否已在监视此项目 + { + let watchers = watcher_state.watchers.lock().await; + if watchers.contains_key(&project_path) { + println!("[UserCode] Already watching: {}", project_path); + return Ok(()); + } + } + + // Create a channel for shutdown signal | 创建关闭信号通道 + let (shutdown_tx, mut shutdown_rx) = tokio::sync::oneshot::channel::<()>(); + + // Clone values for the spawned task | 克隆值以供任务使用 + let project_path_clone = project_path.clone(); + let watch_path_clone = watch_path.clone(); + let app_clone = app.clone(); + + // Spawn file watcher task | 启动文件监视任务 + tokio::spawn(async move { + // Create notify watcher | 创建 notify 监视器 + let (tx, rx) = channel(); + + let mut watcher = match RecommendedWatcher::new( + move |res: Result| { + if let Ok(event) = res { + let _ = tx.send(event); + } + }, + Config::default().with_poll_interval(Duration::from_millis(500)), + ) { + Ok(w) => w, + Err(e) => { + eprintln!("[UserCode] Failed to create watcher: {}", e); + return; + } + }; + + // Start watching | 开始监视 + if let Err(e) = watcher.watch(&watch_path_clone, RecursiveMode::Recursive) { + eprintln!("[UserCode] Failed to watch path: {}", e); + return; + } + + println!("[UserCode] Started watching: {}", watch_path_clone.display()); + + // Event loop | 事件循环 + loop { + // Check for shutdown | 检查关闭信号 + if shutdown_rx.try_recv().is_ok() { + println!("[UserCode] Stopping watcher for: {}", project_path_clone); + break; + } + + // Check for file events with timeout | 带超时检查文件事件 + match rx.recv_timeout(Duration::from_millis(100)) { + Ok(event) => { + // Filter for TypeScript/JavaScript files | 过滤 TypeScript/JavaScript 文件 + let ts_paths: Vec = event + .paths + .iter() + .filter(|p| { + let ext = p.extension().and_then(|e| e.to_str()).unwrap_or(""); + matches!(ext, "ts" | "tsx" | "js" | "jsx") + }) + .map(|p| p.to_string_lossy().to_string()) + .collect(); + + if !ts_paths.is_empty() { + let change_type = match event.kind { + EventKind::Create(_) => "create", + EventKind::Modify(_) => "modify", + EventKind::Remove(_) => "remove", + _ => continue, + }; + + let file_event = FileChangeEvent { + change_type: change_type.to_string(), + paths: ts_paths, + }; + + println!("[UserCode] File change detected: {:?}", file_event); + + // Emit event to frontend | 向前端发送事件 + if let Err(e) = app_clone.emit("user-code:file-changed", file_event) { + eprintln!("[UserCode] Failed to emit event: {}", e); + } + } + } + Err(std::sync::mpsc::RecvTimeoutError::Timeout) => { + // No events, continue | 无事件,继续 + } + Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => { + println!("[UserCode] Watcher channel disconnected"); + break; + } + } + } + }); + + // Store watcher handle | 存储监视器句柄 + { + let mut watchers = watcher_state.watchers.lock().await; + watchers.insert( + project_path.clone(), + crate::state::WatcherHandle { shutdown_tx }, + ); + } + + println!("[UserCode] Watch scripts started for: {}/{}", project_path, scripts_dir); + Ok(()) +} + +/// Stop watching for file changes. +/// 停止监视文件变更。 +#[command] +pub async fn stop_watch_scripts( + watcher_state: State<'_, ScriptWatcherState>, + project_path: Option, +) -> Result<(), String> { + let mut watchers = watcher_state.watchers.lock().await; + + match project_path { + Some(path) => { + // Stop specific watcher | 停止特定监视器 + if let Some(handle) = watchers.remove(&path) { + let _ = handle.shutdown_tx.send(()); + println!("[UserCode] Stopped watching: {}", path); + } + } + None => { + // Stop all watchers | 停止所有监视器 + for (path, handle) in watchers.drain() { + let _ = handle.shutdown_tx.send(()); + println!("[UserCode] Stopped watching: {}", path); + } + } + } + + Ok(()) +} + +/// Find esbuild executable path. +/// 查找 esbuild 可执行文件路径。 +fn find_esbuild(project_root: &str) -> Result { + let project_path = Path::new(project_root); + + // Try local node_modules first | 首先尝试本地 node_modules + let local_esbuild = if cfg!(windows) { + project_path.join("node_modules/.bin/esbuild.cmd") + } else { + project_path.join("node_modules/.bin/esbuild") + }; + + if local_esbuild.exists() { + return Ok(local_esbuild.to_string_lossy().to_string()); + } + + // Try global esbuild | 尝试全局 esbuild + let global_esbuild = if cfg!(windows) { "esbuild.cmd" } else { "esbuild" }; + + // Check if global esbuild exists | 检查全局 esbuild 是否存在 + let check = Command::new(global_esbuild) + .arg("--version") + .output(); + + match check { + Ok(output) if output.status.success() => Ok(global_esbuild.to_string()), + _ => Err("esbuild not found. Please install esbuild: npm install -g esbuild | 未找到 esbuild,请安装: npm install -g esbuild".to_string()) + } +} + +/// Parse esbuild error output. +/// 解析 esbuild 错误输出。 +fn parse_esbuild_errors(stderr: &str) -> Vec { + let mut errors = Vec::new(); + + // Simple error parsing - esbuild outputs errors in a specific format + // 简单的错误解析 - esbuild 以特定格式输出错误 + for line in stderr.lines() { + if line.contains("error:") || line.contains("Error:") { + // Try to parse file:line:column format | 尝试解析 file:line:column 格式 + let parts: Vec<&str> = line.splitn(2, ": ").collect(); + + if parts.len() == 2 { + let location = parts[0]; + let message = parts[1].to_string(); + + // Parse location (file:line:column) | 解析位置 + let loc_parts: Vec<&str> = location.split(':').collect(); + + let (file, line_num, column) = if loc_parts.len() >= 3 { + ( + Some(loc_parts[0].to_string()), + loc_parts[1].parse().ok(), + loc_parts[2].parse().ok(), + ) + } else { + (None, None, None) + }; + + errors.push(CompileError { + message, + file, + line: line_num, + column, + }); + } else { + errors.push(CompileError { + message: line.to_string(), + file: None, + line: None, + column: None, + }); + } + } + } + + // If no specific errors found, add the whole stderr as one error + // 如果没有找到特定错误,将整个 stderr 作为一个错误 + if errors.is_empty() && !stderr.trim().is_empty() { + errors.push(CompileError { + message: stderr.to_string(), + file: None, + line: None, + column: None, + }); + } + + errors +} diff --git a/packages/editor-app/src-tauri/src/commands/mod.rs b/packages/editor-app/src-tauri/src/commands/mod.rs index 6b7bdbd9..676c01c9 100644 --- a/packages/editor-app/src-tauri/src/commands/mod.rs +++ b/packages/editor-app/src-tauri/src/commands/mod.rs @@ -1,17 +1,25 @@ -//! Command modules +//! Command modules. +//! 命令模块。 //! //! All Tauri commands organized by domain. +//! 所有按领域组织的 Tauri 命令。 +pub mod build; +pub mod compiler; pub mod dialog; pub mod file_system; +pub mod modules; pub mod plugin; pub mod profiler; pub mod project; pub mod system; -// Re-export all commands for convenience +// Re-export all commands for convenience | 重新导出所有命令以方便使用 +pub use build::*; +pub use compiler::*; pub use dialog::*; pub use file_system::*; +pub use modules::*; pub use plugin::*; pub use profiler::*; pub use project::*; diff --git a/packages/editor-app/src-tauri/src/commands/modules.rs b/packages/editor-app/src-tauri/src/commands/modules.rs new file mode 100644 index 00000000..45afe974 --- /dev/null +++ b/packages/editor-app/src-tauri/src/commands/modules.rs @@ -0,0 +1,175 @@ +//! Engine Module Commands +//! 引擎模块命令 +//! +//! Commands for reading engine module configurations. +//! 用于读取引擎模块配置的命令。 + +use std::path::PathBuf; +use tauri::{command, AppHandle}; + +#[cfg(not(debug_assertions))] +use tauri::Manager; + +/// Module index structure. +/// 模块索引结构。 +#[derive(serde::Serialize, serde::Deserialize)] +pub struct ModuleIndex { + pub version: String, + #[serde(rename = "generatedAt")] + pub generated_at: String, + pub modules: Vec, +} + +/// Module index entry. +/// 模块索引条目。 +#[derive(serde::Serialize, serde::Deserialize)] +pub struct ModuleIndexEntry { + pub id: String, + pub name: String, + #[serde(rename = "displayName")] + pub display_name: String, + #[serde(rename = "hasRuntime")] + pub has_runtime: bool, + #[serde(rename = "editorPackage")] + pub editor_package: Option, + #[serde(rename = "isCore")] + pub is_core: bool, + pub category: String, + /// JS bundle size in bytes | JS 包大小(字节) + #[serde(rename = "jsSize")] + pub js_size: Option, + /// Whether this module requires WASM | 是否需要 WASM + #[serde(rename = "requiresWasm")] + pub requires_wasm: Option, + /// WASM file size in bytes | WASM 文件大小(字节) + #[serde(rename = "wasmSize")] + pub wasm_size: Option, +} + +/// Get the engine modules directory path. +/// 获取引擎模块目录路径。 +/// +/// Uses compile-time CARGO_MANIFEST_DIR in dev mode to locate dist/engine. +/// 在开发模式下使用编译时的 CARGO_MANIFEST_DIR 来定位 dist/engine。 +#[allow(unused_variables)] +fn get_engine_modules_path(app: &AppHandle) -> Result { + // In development mode, use compile-time path + // 在开发模式下,使用编译时路径 + #[cfg(debug_assertions)] + { + // CARGO_MANIFEST_DIR is set at compile time, pointing to src-tauri + // CARGO_MANIFEST_DIR 在编译时设置,指向 src-tauri + let dev_path = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .parent() + .map(|p| p.join("dist/engine")) + .unwrap_or_else(|| PathBuf::from("dist/engine")); + + if dev_path.exists() { + println!("[modules] Using dev path: {:?}", dev_path); + return Ok(dev_path); + } + + // Fallback: try current working directory + // 回退:尝试当前工作目录 + let cwd_path = std::env::current_dir() + .map(|p| p.join("dist/engine")) + .unwrap_or_else(|_| PathBuf::from("dist/engine")); + + if cwd_path.exists() { + println!("[modules] Using cwd path: {:?}", cwd_path); + return Ok(cwd_path); + } + + return Err(format!( + "Engine modules directory not found in dev mode. Tried: {:?}, {:?}. Run 'pnpm copy-modules' first.", + dev_path, cwd_path + )); + } + + // Production: use resource directory + // 生产环境:使用资源目录 + #[cfg(not(debug_assertions))] + { + let resource_path = app + .path() + .resource_dir() + .map_err(|e| format!("Failed to get resource dir: {}", e))?; + + let prod_path = resource_path.join("engine"); + + if prod_path.exists() { + return Ok(prod_path); + } + + // Fallback: try exe directory + // 回退:尝试可执行文件目录 + let exe_path = std::env::current_exe() + .map_err(|e| format!("Failed to get exe path: {}", e))?; + let exe_dir = exe_path.parent() + .ok_or("Failed to get exe directory")?; + + let exe_engine_path = exe_dir.join("engine"); + if exe_engine_path.exists() { + return Ok(exe_engine_path); + } + + Err(format!( + "Engine modules directory not found. Tried: {:?}, {:?}", + prod_path, exe_engine_path + )) + } +} + +/// Read the engine modules index. +/// 读取引擎模块索引。 +#[command] +pub async fn read_engine_modules_index(app: AppHandle) -> Result { + println!("[modules] read_engine_modules_index called"); + let engine_path = get_engine_modules_path(&app)?; + println!("[modules] engine_path: {:?}", engine_path); + let index_path = engine_path.join("index.json"); + + if !index_path.exists() { + return Err(format!( + "Module index not found at {:?}. Run 'pnpm copy-modules' first.", + index_path + )); + } + + let content = std::fs::read_to_string(&index_path) + .map_err(|e| format!("Failed to read index.json: {}", e))?; + + serde_json::from_str(&content) + .map_err(|e| format!("Failed to parse index.json: {}", e)) +} + +/// Read a specific module's manifest. +/// 读取特定模块的清单。 +#[command] +pub async fn read_module_manifest(app: AppHandle, module_id: String) -> Result { + let engine_path = get_engine_modules_path(&app)?; + let manifest_path = engine_path.join(&module_id).join("module.json"); + + if !manifest_path.exists() { + return Err(format!( + "Module manifest not found for '{}' at {:?}", + module_id, manifest_path + )); + } + + let content = std::fs::read_to_string(&manifest_path) + .map_err(|e| format!("Failed to read module.json for {}: {}", module_id, e))?; + + serde_json::from_str(&content) + .map_err(|e| format!("Failed to parse module.json for {}: {}", module_id, e)) +} + +/// Get the base path to engine modules directory. +/// 获取引擎模块目录的基础路径。 +#[command] +pub async fn get_engine_modules_base_path(app: AppHandle) -> Result { + let path = get_engine_modules_path(&app)?; + path.to_str() + .map(|s| s.to_string()) + .ok_or_else(|| "Failed to convert path to string".to_string()) +} diff --git a/packages/editor-app/src-tauri/src/main.rs b/packages/editor-app/src-tauri/src/main.rs index df7c2fee..db68af26 100644 --- a/packages/editor-app/src-tauri/src/main.rs +++ b/packages/editor-app/src-tauri/src/main.rs @@ -12,14 +12,15 @@ use std::collections::HashMap; use std::sync::{Arc, Mutex}; use tauri::Manager; -use state::{ProfilerState, ProjectPaths}; +use state::{ProfilerState, ProjectPaths, ScriptWatcherState}; fn main() { - // Initialize shared state + // Initialize shared state | 初始化共享状态 let project_paths: ProjectPaths = Arc::new(Mutex::new(HashMap::new())); let project_paths_for_protocol = Arc::clone(&project_paths); let profiler_state = ProfilerState::new(); + let script_watcher_state = ScriptWatcherState::new(); // Build and run the Tauri application tauri::Builder::default() @@ -34,10 +35,11 @@ fn main() { .register_uri_scheme_protocol("project", move |_app, request| { handle_project_protocol(request, &project_paths_for_protocol) }) - // Setup application state + // Setup application state | 设置应用状态 .setup(move |app| { app.manage(project_paths); app.manage(profiler_state); + app.manage(script_watcher_state); Ok(()) }) // Register all commands @@ -85,6 +87,24 @@ fn main() { commands::stop_local_server, commands::get_local_ip, commands::generate_qrcode, + // User code compilation | 用户代码编译 + commands::compile_typescript, + commands::watch_scripts, + commands::stop_watch_scripts, + // Build commands | 构建命令 + commands::prepare_build_directory, + commands::copy_directory, + commands::bundle_scripts, + commands::generate_html, + commands::get_file_size, + commands::get_directory_size, + commands::write_json_file, + commands::list_files_by_extension, + commands::read_binary_file_as_base64, + // Engine modules | 引擎模块 + commands::read_engine_modules_index, + commands::read_module_manifest, + commands::get_engine_modules_base_path, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/packages/editor-app/src-tauri/src/state.rs b/packages/editor-app/src-tauri/src/state.rs index 8b0e1e74..4d98dbbf 100644 --- a/packages/editor-app/src-tauri/src/state.rs +++ b/packages/editor-app/src-tauri/src/state.rs @@ -1,17 +1,52 @@ -//! Application state definitions +//! Application state definitions. +//! 应用状态定义。 //! //! Centralized state management for the Tauri application. +//! Tauri 应用的集中状态管理。 use std::collections::HashMap; use std::sync::{Arc, Mutex}; use tokio::sync::Mutex as TokioMutex; use crate::profiler_ws::ProfilerServer; -/// Project paths state +/// Project paths state. +/// 项目路径状态。 /// /// Stores the current project path and other path-related information. +/// 存储当前项目路径和其他路径相关信息。 pub type ProjectPaths = Arc>>; +/// Script watcher state. +/// 脚本监视器状态。 +/// +/// Manages file watchers for hot reload functionality. +/// 管理用于热重载功能的文件监视器。 +pub struct ScriptWatcherState { + /// Active watchers keyed by project path | 按项目路径索引的活动监视器 + pub watchers: Arc>>, +} + +/// Handle to a running file watcher. +/// 正在运行的文件监视器句柄。 +pub struct WatcherHandle { + /// Shutdown signal sender | 关闭信号发送器 + pub shutdown_tx: tokio::sync::oneshot::Sender<()>, +} + +impl ScriptWatcherState { + pub fn new() -> Self { + Self { + watchers: Arc::new(TokioMutex::new(HashMap::new())), + } + } +} + +impl Default for ScriptWatcherState { + fn default() -> Self { + Self::new() + } +} + /// Profiler server state /// /// Manages the lifecycle of the WebSocket profiler server. diff --git a/packages/editor-app/src/App.tsx b/packages/editor-app/src/App.tsx index 01536f42..8e79fa9b 100644 --- a/packages/editor-app/src/App.tsx +++ b/packages/editor-app/src/App.tsx @@ -24,7 +24,8 @@ import { ICompilerRegistry, InspectorRegistry, INotification, - CommandManager + CommandManager, + BuildService } from '@esengine/editor-core'; import type { IDialogExtended } from './services/TauriDialogService'; import { GlobalBlackboardService } from '@esengine/behavior-tree'; @@ -42,6 +43,7 @@ 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 { ToastProvider, useToast } from './components/Toast'; import { TitleBar } from './components/TitleBar'; import { MainToolbar } from './components/MainToolbar'; @@ -95,6 +97,7 @@ function App() { const [sceneManager, setSceneManager] = useState(null); const [notification, setNotification] = useState(null); const [dialog, setDialog] = useState(null); + const [buildService, setBuildService] = useState(null); const [commandManager] = useState(() => new CommandManager()); const { t, locale, changeLocale } = useLocale(); @@ -117,6 +120,7 @@ function App() { showSettings, setShowSettings, showAbout, setShowAbout, showPluginGenerator, setShowPluginGenerator, + showBuildSettings, setShowBuildSettings, errorDialog, setErrorDialog, confirmDialog, setConfirmDialog } = useDialogStore(); @@ -285,6 +289,7 @@ function App() { setSceneManager(services.sceneManager); setNotification(services.notification); setDialog(services.dialog as IDialogExtended); + setBuildService(services.buildService); setStatus(t('header.status.ready')); // Check for updates on startup (after 3 seconds) @@ -768,7 +773,7 @@ function App() { let content: React.ReactNode; if (panelDesc.component) { const Component = panelDesc.component; - content = ; + content = ; } else if (panelDesc.render) { content = panelDesc.render(); } @@ -883,6 +888,7 @@ function App() { onOpenAbout={handleOpenAbout} onCreatePlugin={handleCreatePlugin} onReloadPlugins={handleReloadPlugins} + onOpenBuildSettings={() => setShowBuildSettings(true)} /> )} + {showBuildSettings && ( + setShowBuildSettings(false)} + projectPath={currentProjectPath || undefined} + locale={locale} + buildService={buildService || undefined} + sceneManager={sceneManager || undefined} + /> + )} + {errorDialog && ( void; setShowAbout: (show: boolean) => void; setShowPluginGenerator: (show: boolean) => void; + setShowBuildSettings: (show: boolean) => void; setErrorDialog: (data: ErrorDialogData | null) => void; setConfirmDialog: (data: ConfirmDialogData | null) => void; closeAllDialogs: () => void; @@ -34,6 +36,7 @@ export const useDialogStore = create((set) => ({ showSettings: false, showAbout: false, showPluginGenerator: false, + showBuildSettings: false, errorDialog: null, confirmDialog: null, @@ -43,6 +46,7 @@ export const useDialogStore = create((set) => ({ setShowSettings: (show) => set({ showSettings: show }), setShowAbout: (show) => set({ showAbout: show }), setShowPluginGenerator: (show) => set({ showPluginGenerator: show }), + setShowBuildSettings: (show) => set({ showBuildSettings: show }), setErrorDialog: (data) => set({ errorDialog: data }), setConfirmDialog: (data) => set({ confirmDialog: data }), @@ -53,6 +57,7 @@ export const useDialogStore = create((set) => ({ showSettings: false, showAbout: false, showPluginGenerator: false, + showBuildSettings: false, errorDialog: null, confirmDialog: null }) diff --git a/packages/editor-app/src/app/managers/PluginInstaller.ts b/packages/editor-app/src/app/managers/PluginInstaller.ts index 37108a12..ef4fa3ea 100644 --- a/packages/editor-app/src/app/managers/PluginInstaller.ts +++ b/packages/editor-app/src/app/managers/PluginInstaller.ts @@ -13,8 +13,8 @@ import { GizmoPlugin } from '../../plugins/builtin/GizmoPlugin'; import { SceneInspectorPlugin } from '../../plugins/builtin/SceneInspectorPlugin'; import { ProfilerPlugin } from '../../plugins/builtin/ProfilerPlugin'; import { EditorAppearancePlugin } from '../../plugins/builtin/EditorAppearancePlugin'; -import { PluginConfigPlugin } from '../../plugins/builtin/PluginConfigPlugin'; import { ProjectSettingsPlugin } from '../../plugins/builtin/ProjectSettingsPlugin'; +// Note: PluginConfigPlugin removed - module management is now unified in ProjectSettingsPlugin // 统一模块插件(从编辑器包导入完整插件,包含 runtime + editor) import { BehaviorTreePlugin } from '@esengine/behavior-tree-editor'; @@ -22,6 +22,9 @@ import { Physics2DPlugin } from '@esengine/physics-rapier2d-editor'; import { TilemapPlugin } from '@esengine/tilemap-editor'; import { UIPlugin } from '@esengine/ui-editor'; import { BlueprintPlugin } from '@esengine/blueprint-editor'; +import { MaterialPlugin } from '@esengine/material-editor'; +import { SpritePlugin } from '@esengine/sprite-editor'; +import { ShaderEditorPlugin } from '@esengine/shader-editor'; export class PluginInstaller { /** @@ -34,13 +37,12 @@ export class PluginInstaller { { name: 'SceneInspectorPlugin', plugin: SceneInspectorPlugin }, { name: 'ProfilerPlugin', plugin: ProfilerPlugin }, { name: 'EditorAppearancePlugin', plugin: EditorAppearancePlugin }, - { name: 'PluginConfigPlugin', plugin: PluginConfigPlugin }, { name: 'ProjectSettingsPlugin', plugin: ProjectSettingsPlugin }, ]; for (const { name, plugin } of builtinPlugins) { - if (!plugin || !plugin.descriptor) { - console.error(`[PluginInstaller] ${name} is invalid: missing descriptor`, plugin); + if (!plugin || !plugin.manifest) { + console.error(`[PluginInstaller] ${name} is invalid: missing manifest`, plugin); continue; } try { @@ -52,20 +54,23 @@ export class PluginInstaller { // 统一模块插件(runtime + editor) const modulePlugins = [ + { name: 'SpritePlugin', plugin: SpritePlugin }, { name: 'TilemapPlugin', plugin: TilemapPlugin }, { name: 'UIPlugin', plugin: UIPlugin }, { name: 'BehaviorTreePlugin', plugin: BehaviorTreePlugin }, { name: 'Physics2DPlugin', plugin: Physics2DPlugin }, { name: 'BlueprintPlugin', plugin: BlueprintPlugin }, + { name: 'MaterialPlugin', plugin: MaterialPlugin }, + { name: 'ShaderEditorPlugin', plugin: ShaderEditorPlugin }, ]; for (const { name, plugin } of modulePlugins) { - if (!plugin || !plugin.descriptor) { - console.error(`[PluginInstaller] ${name} is invalid: missing descriptor`, plugin); + if (!plugin || !plugin.manifest) { + console.error(`[PluginInstaller] ${name} is invalid: missing manifest`, plugin); continue; } // 详细日志,检查 editorModule 是否存在 - console.log(`[PluginInstaller] ${name}: descriptor.id=${plugin.descriptor.id}, hasRuntimeModule=${!!plugin.runtimeModule}, hasEditorModule=${!!plugin.editorModule}`); + console.log(`[PluginInstaller] ${name}: manifest.id=${plugin.manifest.id}, hasRuntimeModule=${!!plugin.runtimeModule}, hasEditorModule=${!!plugin.editorModule}`); try { pluginManager.register(plugin); } catch (error) { diff --git a/packages/editor-app/src/app/managers/ServiceRegistry.ts b/packages/editor-app/src/app/managers/ServiceRegistry.ts index d25f4e4e..dfd3b709 100644 --- a/packages/editor-app/src/app/managers/ServiceRegistry.ts +++ b/packages/editor-app/src/app/managers/ServiceRegistry.ts @@ -1,4 +1,5 @@ import { Core, ComponentRegistry as CoreComponentRegistry } from '@esengine/ecs-framework'; +import { invoke } from '@tauri-apps/api/core'; import { UIRegistry, MessageHub, @@ -27,8 +28,18 @@ import { IDialogService, IFileSystemService, CompilerRegistry, - ICompilerRegistry + ICompilerRegistry, + IViewportService_ID, + IPreviewSceneService, + IEditorViewportServiceIdentifier, + PreviewSceneService, + EditorViewportService, + BuildService, + WebBuildPipeline, + WeChatBuildPipeline, + moduleRegistry } from '@esengine/editor-core'; +import { ViewportService } from '../../services/ViewportService'; import { TransformComponent } from '@esengine/engine-core'; import { SpriteComponent, SpriteAnimatorComponent } from '@esengine/sprite'; import { CameraComponent } from '@esengine/camera'; @@ -65,6 +76,8 @@ import { AnimationClipsFieldEditor } from '../../infrastructure/field-editors'; import { TransformComponentInspector } from '../../components/inspectors/component-inspectors/TransformComponentInspector'; +import { buildFileSystem } from '../../services/BuildFileSystemService'; +import { TauriModuleFileSystem } from '../../services/TauriModuleFileSystem'; export interface EditorServices { uiRegistry: UIRegistry; @@ -90,6 +103,7 @@ export interface EditorServices { inspectorRegistry: InspectorRegistry; propertyRendererRegistry: PropertyRendererRegistry; fieldEditorRegistry: FieldEditorRegistry; + buildService: BuildService; } export class ServiceRegistry { @@ -172,6 +186,22 @@ export class ServiceRegistry { Core.services.registerInstance(IDialogService, dialog); Core.services.registerInstance(IFileSystemService, fileSystem); + // Register viewport service for editor panels + // 注册视口服务供编辑器面板使用 + const viewportService = ViewportService.getInstance(); + Core.services.registerInstance(IViewportService_ID, viewportService); + + // Register preview scene service for isolated preview scenes + // 注册预览场景服务,用于隔离的预览场景 + const previewSceneService = PreviewSceneService.getInstance(); + Core.services.registerInstance(IPreviewSceneService, previewSceneService); + + // Register editor viewport service for coordinating viewports with overlays + // 注册编辑器视口服务,协调带有覆盖层的视口 + const editorViewportService = EditorViewportService.getInstance(); + editorViewportService.setViewportService(viewportService); + Core.services.registerInstance(IEditorViewportServiceIdentifier, editorViewportService); + const inspectorRegistry = new InspectorRegistry(); Core.services.registerInstance(InspectorRegistry, inspectorRegistry); Core.services.registerInstance(IInspectorRegistry, inspectorRegistry); // Symbol 注册用于跨包插件访问 @@ -204,6 +234,43 @@ export class ServiceRegistry { // Register component inspectors componentInspectorRegistry.register(new TransformComponentInspector()); + // 注册构建服务 + // Register build service + const buildService = new BuildService(); + + // Register Web build pipeline with file system service + // 注册 Web 构建管线并注入文件系统服务 + const webPipeline = new WebBuildPipeline(); + webPipeline.setFileSystem(buildFileSystem); + + // Get engine modules path from Tauri backend + // 从 Tauri 后端获取引擎模块路径 + invoke('get_engine_modules_base_path').then(enginePath => { + console.log('[ServiceRegistry] Engine modules path:', enginePath); + webPipeline.setEngineModulesPath(enginePath); + }).catch(err => { + console.warn('[ServiceRegistry] Failed to get engine modules path:', err); + }); + + buildService.register(webPipeline); + + // Register WeChat build pipeline + // 注册微信构建管线 + const wechatPipeline = new WeChatBuildPipeline(); + wechatPipeline.setFileSystem(buildFileSystem); + buildService.register(wechatPipeline); + + Core.services.registerInstance(BuildService, buildService); + + // Initialize ModuleRegistry with Tauri file system + // 使用 Tauri 文件系统初始化 ModuleRegistry + // Engine modules are read via Tauri commands from local file system + // 引擎模块通过 Tauri 命令从本地文件系统读取 + const tauriModuleFs = new TauriModuleFileSystem(); + moduleRegistry.initialize(tauriModuleFs, '/engine').catch(err => { + console.warn('[ServiceRegistry] Failed to initialize ModuleRegistry:', err); + }); + // 注册默认场景模板 - 创建默认相机 // Register default scene template - creates default camera this.registerDefaultSceneTemplate(); @@ -231,7 +298,8 @@ export class ServiceRegistry { notification, inspectorRegistry, propertyRendererRegistry, - fieldEditorRegistry + fieldEditorRegistry, + buildService }; } diff --git a/packages/editor-app/src/components/BuildSettingsPanel.tsx b/packages/editor-app/src/components/BuildSettingsPanel.tsx new file mode 100644 index 00000000..8f391961 --- /dev/null +++ b/packages/editor-app/src/components/BuildSettingsPanel.tsx @@ -0,0 +1,897 @@ +/** + * Build Settings Panel. + * 构建设置面板。 + * + * Provides build settings interface for managing platform builds, + * scenes, and player settings. + * 提供构建设置界面,用于管理平台构建、场景和玩家设置。 + */ + +import { useState, useEffect, useCallback, useRef } from 'react'; +import { + Monitor, Apple, Smartphone, Globe, Server, Gamepad2, + Plus, Minus, ChevronDown, ChevronRight, Settings, + Package, Loader2, CheckCircle, XCircle, AlertTriangle, X +} from 'lucide-react'; +import type { BuildService, BuildProgress, BuildConfig, WebBuildConfig, WeChatBuildConfig, SceneManagerService } from '@esengine/editor-core'; +import { BuildPlatform, BuildStatus } from '@esengine/editor-core'; +import '../styles/BuildSettingsPanel.css'; + +// ==================== Types | 类型定义 ==================== + +/** Platform type | 平台类型 */ +type PlatformType = + | 'windows' + | 'macos' + | 'linux' + | 'android' + | 'ios' + | 'web' + | 'wechat-minigame'; + +/** Build profile | 构建配置 */ +interface BuildProfile { + id: string; + name: string; + platform: PlatformType; + isActive?: boolean; +} + +/** Scene entry | 场景条目 */ +interface SceneEntry { + path: string; + enabled: boolean; +} + +/** Platform configuration | 平台配置 */ +interface PlatformConfig { + platform: PlatformType; + label: string; + icon: React.ReactNode; + available: boolean; +} + +/** Build settings | 构建设置 */ +interface BuildSettings { + scenes: SceneEntry[]; + scriptingDefines: string[]; + companyName: string; + productName: string; + version: string; + // Platform-specific | 平台特定 + developmentBuild: boolean; + sourceMap: boolean; + compressionMethod: 'Default' | 'LZ4' | 'LZ4HC'; + bundleModules: boolean; +} + +// ==================== Constants | 常量 ==================== + +const PLATFORMS: PlatformConfig[] = [ + { platform: 'windows', label: 'Windows', icon: , available: true }, + { platform: 'macos', label: 'macOS', icon: , available: true }, + { platform: 'linux', label: 'Linux', icon: , available: true }, + { platform: 'android', label: 'Android', icon: , available: true }, + { platform: 'ios', label: 'iOS', icon: , available: true }, + { platform: 'web', label: 'Web', icon: , available: true }, + { platform: 'wechat-minigame', label: 'WeChat Mini Game', icon: , available: true }, +]; + +const DEFAULT_SETTINGS: BuildSettings = { + scenes: [], + scriptingDefines: [], + companyName: 'DefaultCompany', + productName: 'MyGame', + version: '0.1.0', + developmentBuild: false, + sourceMap: false, + compressionMethod: 'Default', + bundleModules: false, +}; + +// ==================== i18n | 国际化 ==================== + +const i18n = { + en: { + buildProfiles: 'Build Profiles', + addBuildProfile: 'Add Build Profile', + playerSettings: 'Player Settings', + assetImportOverrides: 'Asset Import Overrides', + platforms: 'Platforms', + sceneList: 'Scene List', + active: 'Active', + switchProfile: 'Switch Profile', + build: 'Build', + buildAndRun: 'Build And Run', + buildData: 'Build Data', + scriptingDefines: 'Scripting Defines', + listIsEmpty: 'List is empty', + addOpenScenes: 'Add Open Scenes', + platformSettings: 'Platform Settings', + architecture: 'Architecture', + developmentBuild: 'Development Build', + sourceMap: 'Source Map', + compressionMethod: 'Compression Method', + bundleModules: 'Bundle Modules', + bundleModulesHint: 'Merge all modules into single file', + separateModulesHint: 'Keep modules as separate files', + playerSettingsOverrides: 'Player Settings Overrides', + companyName: 'Company Name', + productName: 'Product Name', + version: 'Version', + defaultIcon: 'Default Icon', + none: 'None', + // Build progress | 构建进度 + buildInProgress: 'Build in Progress', + preparing: 'Preparing...', + compiling: 'Compiling...', + packaging: 'Packaging assets...', + copying: 'Copying files...', + postProcessing: 'Post-processing...', + completed: 'Completed', + failed: 'Failed', + cancelled: 'Cancelled', + cancel: 'Cancel', + close: 'Close', + buildSucceeded: 'Build succeeded!', + buildFailed: 'Build failed', + warnings: 'Warnings', + outputPath: 'Output Path', + duration: 'Duration', + }, + zh: { + buildProfiles: '构建配置', + addBuildProfile: '添加构建配置', + playerSettings: '玩家设置', + assetImportOverrides: '资源导入覆盖', + platforms: '平台', + sceneList: '场景列表', + active: '激活', + switchProfile: '切换配置', + build: '构建', + buildAndRun: '构建并运行', + buildData: '构建数据', + scriptingDefines: '脚本定义', + listIsEmpty: '列表为空', + addOpenScenes: '添加已打开的场景', + platformSettings: '平台设置', + architecture: '架构', + developmentBuild: '开发版本', + sourceMap: 'Source Map', + compressionMethod: '压缩方式', + bundleModules: '打包模块', + bundleModulesHint: '合并所有模块为单文件', + separateModulesHint: '保持模块为独立文件', + playerSettingsOverrides: '玩家设置覆盖', + companyName: '公司名称', + productName: '产品名称', + version: '版本', + defaultIcon: '默认图标', + none: '无', + // Build progress | 构建进度 + buildInProgress: '正在构建', + preparing: '准备中...', + compiling: '编译中...', + packaging: '打包资源...', + copying: '复制文件...', + postProcessing: '后处理...', + completed: '已完成', + failed: '失败', + cancelled: '已取消', + cancel: '取消', + close: '关闭', + buildSucceeded: '构建成功!', + buildFailed: '构建失败', + warnings: '警告', + outputPath: '输出路径', + duration: '耗时', + } +}; + +// ==================== Props | 属性 ==================== + +interface BuildSettingsPanelProps { + projectPath?: string; + locale?: string; + buildService?: BuildService; + sceneManager?: SceneManagerService; + onBuild?: (profile: BuildProfile, settings: BuildSettings) => void; + onClose?: () => void; +} + +// ==================== Component | 组件 ==================== + +export function BuildSettingsPanel({ + projectPath, + locale = 'en', + buildService, + sceneManager, + onBuild, + onClose +}: BuildSettingsPanelProps) { + const t = i18n[locale as keyof typeof i18n] || i18n.en; + + // State | 状态 + const [profiles, setProfiles] = useState([ + { id: 'web-dev', name: 'Web - Development', platform: 'web', isActive: true }, + { id: 'web-prod', name: 'Web - Production', platform: 'web' }, + { id: 'wechat', name: 'WeChat Mini Game', platform: 'wechat-minigame' }, + ]); + const [selectedPlatform, setSelectedPlatform] = useState('web'); + const [selectedProfile, setSelectedProfile] = useState(profiles[0] || null); + const [settings, setSettings] = useState(DEFAULT_SETTINGS); + const [expandedSections, setExpandedSections] = useState>({ + sceneList: true, + scriptingDefines: true, + platformSettings: true, + playerSettings: true, + }); + + // Build state | 构建状态 + const [isBuilding, setIsBuilding] = useState(false); + const [buildProgress, setBuildProgress] = useState(null); + const [buildResult, setBuildResult] = useState<{ + success: boolean; + outputPath: string; + duration: number; + warnings: string[]; + error?: string; + } | null>(null); + const [showBuildProgress, setShowBuildProgress] = useState(false); + const buildAbortRef = useRef(null); + + // Handlers | 处理函数 + const toggleSection = useCallback((section: string) => { + setExpandedSections(prev => ({ + ...prev, + [section]: !prev[section] + })); + }, []); + + const handlePlatformSelect = useCallback((platform: PlatformType) => { + setSelectedPlatform(platform); + // Find first profile for this platform | 查找此平台的第一个配置 + const profile = profiles.find(p => p.platform === platform); + setSelectedProfile(profile || null); + }, [profiles]); + + const handleProfileSelect = useCallback((profile: BuildProfile) => { + setSelectedProfile(profile); + setSelectedPlatform(profile.platform); + }, []); + + const handleAddProfile = useCallback(() => { + const newProfile: BuildProfile = { + id: `profile-${Date.now()}`, + name: `${selectedPlatform} - New Profile`, + platform: selectedPlatform, + }; + setProfiles(prev => [...prev, newProfile]); + setSelectedProfile(newProfile); + }, [selectedPlatform]); + + // Map platform type to BuildPlatform enum | 将平台类型映射到 BuildPlatform 枚举 + const getPlatformEnum = useCallback((platformType: PlatformType): BuildPlatform => { + const platformMap: Record = { + 'web': BuildPlatform.Web, + 'wechat-minigame': BuildPlatform.WeChatMiniGame, + 'windows': BuildPlatform.Desktop, + 'macos': BuildPlatform.Desktop, + 'linux': BuildPlatform.Desktop, + 'android': BuildPlatform.Android, + 'ios': BuildPlatform.iOS + }; + return platformMap[platformType]; + }, []); + + const handleBuild = useCallback(async () => { + if (!selectedProfile || !projectPath) { + return; + } + + // Call external handler if provided | 如果提供了外部处理程序则调用 + if (onBuild) { + onBuild(selectedProfile, settings); + } + + // Use BuildService if available | 如果可用则使用 BuildService + if (buildService) { + setIsBuilding(true); + setBuildProgress(null); + setBuildResult(null); + setShowBuildProgress(true); + + try { + const platform = getPlatformEnum(selectedProfile.platform); + const baseConfig = { + platform, + outputPath: `${projectPath}/build/${selectedProfile.platform}`, + isRelease: !settings.developmentBuild, + sourceMap: settings.sourceMap, + scenes: settings.scenes.filter(s => s.enabled).map(s => s.path) + }; + + // Build platform-specific config | 构建平台特定配置 + let buildConfig: BuildConfig; + if (platform === BuildPlatform.Web) { + const webConfig: WebBuildConfig = { + ...baseConfig, + platform: BuildPlatform.Web, + format: 'iife', + bundleModules: settings.bundleModules, + generateHtml: true + }; + buildConfig = webConfig; + } else if (platform === BuildPlatform.WeChatMiniGame) { + const wechatConfig: WeChatBuildConfig = { + ...baseConfig, + platform: BuildPlatform.WeChatMiniGame, + appId: '', + useSubpackages: false, + mainPackageLimit: 4096, + usePlugins: false + }; + buildConfig = wechatConfig; + } else { + buildConfig = baseConfig; + } + + // Execute build with progress callback | 执行构建并传入进度回调 + const result = await buildService.build(buildConfig, (progress) => { + setBuildProgress(progress); + }); + + // Set result | 设置结果 + setBuildResult({ + success: result.success, + outputPath: result.outputPath, + duration: result.duration, + warnings: result.warnings, + error: result.error + }); + + } catch (error) { + console.error('Build failed:', error); + setBuildResult({ + success: false, + outputPath: '', + duration: 0, + warnings: [], + error: error instanceof Error ? error.message : String(error) + }); + } finally { + setIsBuilding(false); + } + } + }, [selectedProfile, settings, projectPath, buildService, onBuild, getPlatformEnum]); + + // Monitor build progress from service | 从服务监控构建进度 + useEffect(() => { + if (!buildService || !isBuilding) { + return; + } + + const interval = setInterval(() => { + const task = buildService.getCurrentTask(); + if (task) { + setBuildProgress(task.progress); + } + }, 100); + + return () => clearInterval(interval); + }, [buildService, isBuilding]); + + const handleCancelBuild = useCallback(() => { + if (buildService) { + buildService.cancelBuild(); + } + }, [buildService]); + + const handleCloseBuildProgress = useCallback(() => { + if (!isBuilding) { + setShowBuildProgress(false); + setBuildProgress(null); + setBuildResult(null); + } + }, [isBuilding]); + + // Get status message | 获取状态消息 + const getStatusMessage = useCallback((status: BuildStatus): string => { + const statusMessages: Record = { + [BuildStatus.Idle]: 'preparing', + [BuildStatus.Preparing]: 'preparing', + [BuildStatus.Compiling]: 'compiling', + [BuildStatus.Packaging]: 'packaging', + [BuildStatus.Copying]: 'copying', + [BuildStatus.PostProcessing]: 'postProcessing', + [BuildStatus.Completed]: 'completed', + [BuildStatus.Failed]: 'failed', + [BuildStatus.Cancelled]: 'cancelled' + }; + return t[statusMessages[status]] || status; + }, [t]); + + const handleAddScene = useCallback(() => { + if (!sceneManager) { + console.warn('SceneManagerService not available'); + return; + } + + const sceneState = sceneManager.getSceneState(); + const currentScenePath = sceneState.currentScenePath; + + if (!currentScenePath) { + console.warn('No scene is currently open'); + return; + } + + // Check if scene is already in the list | 检查场景是否已在列表中 + const exists = settings.scenes.some(s => s.path === currentScenePath); + if (exists) { + console.log('Scene already in list:', currentScenePath); + return; + } + + // Add current scene to the list | 将当前场景添加到列表中 + setSettings(prev => ({ + ...prev, + scenes: [...prev.scenes, { path: currentScenePath, enabled: true }] + })); + }, [sceneManager, settings.scenes]); + + const handleAddDefine = useCallback(() => { + const define = prompt('Enter scripting define:'); + if (define) { + setSettings(prev => ({ + ...prev, + scriptingDefines: [...prev.scriptingDefines, define] + })); + } + }, []); + + const handleRemoveDefine = useCallback((index: number) => { + setSettings(prev => ({ + ...prev, + scriptingDefines: prev.scriptingDefines.filter((_, i) => i !== index) + })); + }, []); + + // Get platform config | 获取平台配置 + const currentPlatformConfig = PLATFORMS.find(p => p.platform === selectedPlatform); + + return ( +
+ {/* Header Tabs | 头部标签 */} +
+
+
+ + {t.buildProfiles} +
+
+
+ + +
+
+ + {/* Add Profile Bar | 添加配置栏 */} +
+ +
+ + {/* Main Content | 主要内容 */} +
+ {/* Left Sidebar | 左侧边栏 */} +
+ {/* Platforms Section | 平台部分 */} +
+
{t.platforms}
+
+ {PLATFORMS.map(platform => { + const isActive = profiles.some(p => p.platform === platform.platform && p.isActive); + return ( +
handlePlatformSelect(platform.platform)} + > + {platform.icon} + {platform.label} + {isActive && {t.active}} +
+ ); + })} +
+
+ + {/* Build Profiles Section | 构建配置部分 */} +
+
{t.buildProfiles}
+
+ {profiles + .filter(p => p.platform === selectedPlatform) + .map(profile => ( +
handleProfileSelect(profile)} + > + + {currentPlatformConfig?.icon} + + {profile.name} +
+ ))} +
+
+
+ + {/* Right Panel | 右侧面板 */} +
+ {selectedProfile ? ( + <> + {/* Profile Header | 配置头部 */} +
+
+ + {currentPlatformConfig?.icon} + +
+

{selectedProfile.name}

+ {currentPlatformConfig?.label} +
+
+
+ + +
+
+ + {/* Build Data Section | 构建数据部分 */} +
+
{t.buildData}
+ + {/* Scene List | 场景列表 */} +
+
toggleSection('sceneList')} + > + {expandedSections.sceneList ? : } + {t.sceneList} +
+ {expandedSections.sceneList && ( +
+
+ {settings.scenes.length === 0 ? ( +
+ ) : ( + settings.scenes.map((scene, index) => ( +
+ + {scene.path} +
+ )) + )} +
+
+ +
+
+ )} +
+ + {/* Scripting Defines | 脚本定义 */} +
+
toggleSection('scriptingDefines')} + > + {expandedSections.scriptingDefines ? : } + {t.scriptingDefines} +
+ {expandedSections.scriptingDefines && ( +
+
+ {settings.scriptingDefines.length === 0 ? ( +
{t.listIsEmpty}
+ ) : ( + settings.scriptingDefines.map((define, index) => ( +
+ {define} + +
+ )) + )} +
+
+ + +
+
+ )} +
+
+ + {/* Platform Settings Section | 平台设置部分 */} +
+
{t.platformSettings}
+ +
+
toggleSection('platformSettings')} + > + {expandedSections.platformSettings ? : } + {currentPlatformConfig?.label} Settings +
+ {expandedSections.platformSettings && ( +
+
+
+ + setSettings(prev => ({ + ...prev, + developmentBuild: e.target.checked + }))} + /> +
+
+ + setSettings(prev => ({ + ...prev, + sourceMap: e.target.checked + }))} + /> +
+
+ + +
+
+ +
+ setSettings(prev => ({ + ...prev, + bundleModules: e.target.checked + }))} + /> + + {settings.bundleModules ? t.bundleModulesHint : t.separateModulesHint} + +
+
+
+
+ )} +
+
+ + {/* Player Settings Overrides | 玩家设置覆盖 */} +
+
+ {t.playerSettingsOverrides} + +
+ +
+
toggleSection('playerSettings')} + > + {expandedSections.playerSettings ? : } + Player Settings +
+ {expandedSections.playerSettings && ( +
+
+
+ + setSettings(prev => ({ + ...prev, + companyName: e.target.value + }))} + /> +
+
+ + setSettings(prev => ({ + ...prev, + productName: e.target.value + }))} + /> +
+
+ + setSettings(prev => ({ + ...prev, + version: e.target.value + }))} + /> +
+
+ +
+ {t.none} + (Texture 2D) +
+
+
+
+ )} +
+
+ + ) : ( +
+

Select a platform or build profile

+
+ )} +
+
+ + {/* Build Progress Dialog | 构建进度对话框 */} + {showBuildProgress && ( +
+
+
+

{t.buildInProgress}

+ {!isBuilding && ( + + )} +
+ +
+ {/* Status Icon | 状态图标 */} +
+ {isBuilding ? ( + + ) : buildResult?.success ? ( + + ) : ( + + )} +
+ + {/* Status Message | 状态消息 */} +
+ {isBuilding ? ( + buildProgress?.message || getStatusMessage(buildProgress?.status || BuildStatus.Preparing) + ) : buildResult?.success ? ( + t.buildSucceeded + ) : ( + t.buildFailed + )} +
+ + {/* Progress Bar | 进度条 */} + {isBuilding && buildProgress && ( +
+
+ + {Math.round(buildProgress.progress)}% + +
+ )} + + {/* Build Result Details | 构建结果详情 */} + {!isBuilding && buildResult && ( +
+ {buildResult.success && ( + <> +
+ {t.outputPath}: + {buildResult.outputPath} +
+
+ {t.duration}: + + {(buildResult.duration / 1000).toFixed(2)}s + +
+ + )} + + {/* Error Message | 错误消息 */} + {buildResult.error && ( +
+ + {buildResult.error} +
+ )} + + {/* Warnings | 警告 */} + {buildResult.warnings.length > 0 && ( +
+
+ + {t.warnings} ({buildResult.warnings.length}) +
+
    + {buildResult.warnings.map((warning, index) => ( +
  • {warning}
  • + ))} +
+
+ )} +
+ )} +
+ + {/* Actions | 操作按钮 */} +
+ {isBuilding ? ( + + ) : ( + + )} +
+
+
+ )} +
+ ); +} + +export default BuildSettingsPanel; diff --git a/packages/editor-app/src/components/BuildSettingsWindow.tsx b/packages/editor-app/src/components/BuildSettingsWindow.tsx new file mode 100644 index 00000000..d0dc06f4 --- /dev/null +++ b/packages/editor-app/src/components/BuildSettingsWindow.tsx @@ -0,0 +1,62 @@ +/** + * Build Settings Window. + * 构建设置窗口。 + * + * A modal window that displays the build settings panel. + * 显示构建设置面板的模态窗口。 + */ + +import { X } from 'lucide-react'; +import type { BuildService, SceneManagerService } from '@esengine/editor-core'; +import { BuildSettingsPanel } from './BuildSettingsPanel'; +import '../styles/BuildSettingsWindow.css'; + +interface BuildSettingsWindowProps { + projectPath?: string; + locale?: string; + buildService?: BuildService; + sceneManager?: SceneManagerService; + onClose: () => void; +} + +export function BuildSettingsWindow({ + projectPath, + locale = 'en', + buildService, + sceneManager, + onClose +}: BuildSettingsWindowProps) { + const t = locale === 'zh' ? { + title: '构建设置' + } : { + title: 'Build Settings' + }; + + return ( +
+
+
+

{t.title}

+ +
+
+ +
+
+
+ ); +} + +export default BuildSettingsWindow; diff --git a/packages/editor-app/src/components/ContentBrowser.tsx b/packages/editor-app/src/components/ContentBrowser.tsx index 476b90e3..0fb3d6ce 100644 --- a/packages/editor-app/src/components/ContentBrowser.tsx +++ b/packages/editor-app/src/components/ContentBrowser.tsx @@ -4,6 +4,7 @@ */ import { useState, useEffect, useRef, useCallback } from 'react'; +import * as LucideIcons from 'lucide-react'; import { Plus, Download, @@ -68,6 +69,21 @@ interface ContentBrowserProps { revealPath?: string | null; } +/** + * 根据图标名获取 Lucide 图标组件 + */ +function getIconComponent(iconName: string | undefined, size: number = 16): React.ReactNode { + if (!iconName) return ; + + const icons = LucideIcons as unknown as Record>; + const IconComponent = icons[iconName]; + if (IconComponent) { + return ; + } + + return ; +} + // 获取资产类型显示名称 function getAssetTypeName(asset: AssetItem): string { if (asset.type === 'folder') return 'Folder'; @@ -156,7 +172,8 @@ export function ContentBrowser({ dockInLayout: 'Dock in Layout', noProject: 'No project loaded', empty: 'This folder is empty', - newFolder: 'New Folder' + newFolder: 'New Folder', + newPrefix: 'New' }, zh: { favorites: '收藏夹', @@ -169,7 +186,8 @@ export function ContentBrowser({ dockInLayout: '停靠到布局', noProject: '未加载项目', empty: '文件夹为空', - newFolder: '新建文件夹' + newFolder: '新建文件夹', + newPrefix: '新建' } }[locale] || { favorites: 'Favorites', @@ -182,7 +200,24 @@ export function ContentBrowser({ dockInLayout: 'Dock in Layout', noProject: 'No project loaded', empty: 'This folder is empty', - newFolder: 'New Folder' + newFolder: 'New Folder', + newPrefix: 'New' + }; + + // 文件创建模板的 label 本地化映射 + const templateLabels: Record = { + 'Material': { en: 'Material', zh: '材质' }, + 'Shader': { en: 'Shader', zh: '着色器' }, + 'Tilemap': { en: 'Tilemap', zh: '瓦片地图' }, + 'Tileset': { en: 'Tileset', zh: '瓦片集' }, + }; + + const getTemplateLabel = (label: string): string => { + const mapping = templateLabels[label]; + if (mapping) { + return locale === 'zh' ? mapping.zh : mapping.en; + } + return label; }; // Build folder tree - use ref to avoid dependency cycle @@ -546,8 +581,10 @@ export function ContentBrowser({ if (templates.length > 0) { items.push({ label: '', separator: true, onClick: () => {} }); for (const template of templates) { + const localizedLabel = getTemplateLabel(template.label); items.push({ - label: `New ${template.label}`, + label: `${t.newPrefix} ${localizedLabel}`, + icon: getIconComponent(template.icon, 16), onClick: () => { setContextMenu(null); if (currentPath) { diff --git a/packages/editor-app/src/components/EditorViewport.tsx b/packages/editor-app/src/components/EditorViewport.tsx new file mode 100644 index 00000000..55c1cd5b --- /dev/null +++ b/packages/editor-app/src/components/EditorViewport.tsx @@ -0,0 +1,390 @@ +/** + * EditorViewport Component + * 编辑器视口组件 + * + * A reusable viewport component for editor panels that need engine rendering. + * Supports camera controls, overlays, and preview scenes. + * + * 用于需要引擎渲染的编辑器面板的可重用视口组件。 + * 支持相机控制、覆盖层和预览场景。 + */ + +import { useEffect, useRef, useCallback, useState, forwardRef, useImperativeHandle } from 'react'; +import type { ViewportCameraConfig, IViewportOverlay } from '@esengine/editor-core'; +import { ViewportService } from '../services/ViewportService'; +import '../styles/EditorViewport.css'; + +/** + * EditorViewport configuration + * 编辑器视口配置 + */ +export interface EditorViewportConfig { + /** Unique viewport identifier | 唯一视口标识符 */ + viewportId: string; + /** Initial camera config | 初始相机配置 */ + initialCamera?: ViewportCameraConfig; + /** Whether to show grid | 是否显示网格 */ + showGrid?: boolean; + /** Whether to show gizmos | 是否显示辅助线 */ + showGizmos?: boolean; + /** Background clear color | 背景清除颜色 */ + clearColor?: { r: number; g: number; b: number; a: number }; + /** Min zoom level | 最小缩放级别 */ + minZoom?: number; + /** Max zoom level | 最大缩放级别 */ + maxZoom?: number; + /** Enable camera pan | 启用相机平移 */ + enablePan?: boolean; + /** Enable camera zoom | 启用相机缩放 */ + enableZoom?: boolean; +} + +/** + * EditorViewport props + * 编辑器视口属性 + */ +export interface EditorViewportProps extends EditorViewportConfig { + /** Class name for styling | 样式类名 */ + className?: string; + /** Called when camera changes | 相机变化时的回调 */ + onCameraChange?: (camera: ViewportCameraConfig) => void; + /** Called when viewport is ready | 视口准备就绪时的回调 */ + onReady?: () => void; + /** Called on mouse down | 鼠标按下时的回调 */ + onMouseDown?: (e: React.MouseEvent, worldPos: { x: number; y: number }) => void; + /** Called on mouse move | 鼠标移动时的回调 */ + onMouseMove?: (e: React.MouseEvent, worldPos: { x: number; y: number }) => void; + /** Called on mouse up | 鼠标抬起时的回调 */ + onMouseUp?: (e: React.MouseEvent, worldPos: { x: number; y: number }) => void; + /** Called on mouse wheel | 鼠标滚轮时的回调 */ + onWheel?: (e: React.WheelEvent, worldPos: { x: number; y: number }) => void; + /** Render custom overlays | 渲染自定义覆盖层 */ + renderOverlays?: () => React.ReactNode; +} + +/** + * EditorViewport handle for imperative access + * 编辑器视口句柄,用于命令式访问 + */ +export interface EditorViewportHandle { + /** Get current camera | 获取当前相机 */ + getCamera(): ViewportCameraConfig; + /** Set camera | 设置相机 */ + setCamera(camera: ViewportCameraConfig): void; + /** Reset camera to initial state | 重置相机到初始状态 */ + resetCamera(): void; + /** Convert screen coordinates to world coordinates | 将屏幕坐标转换为世界坐标 */ + screenToWorld(screenX: number, screenY: number): { x: number; y: number }; + /** Convert world coordinates to screen coordinates | 将世界坐标转换为屏幕坐标 */ + worldToScreen(worldX: number, worldY: number): { x: number; y: number }; + /** Get canvas element | 获取画布元素 */ + getCanvas(): HTMLCanvasElement | null; + /** Request render | 请求渲染 */ + requestRender(): void; +} + +/** + * EditorViewport Component + * 编辑器视口组件 + */ +export const EditorViewport = forwardRef(function EditorViewport( + { + viewportId, + initialCamera = { x: 0, y: 0, zoom: 1 }, + showGrid = true, + showGizmos = false, + clearColor, + minZoom = 0.1, + maxZoom = 10, + enablePan = true, + enableZoom = true, + className, + onCameraChange, + onReady, + onMouseDown, + onMouseMove, + onMouseUp, + onWheel, + renderOverlays + }, + ref +) { + const canvasRef = useRef(null); + const containerRef = useRef(null); + const [isReady, setIsReady] = useState(false); + + // Camera state + const [camera, setCamera] = useState(initialCamera); + const cameraRef = useRef(camera); + + // Drag state + const isDraggingRef = useRef(false); + const lastMousePosRef = useRef({ x: 0, y: 0 }); + + // Keep camera ref in sync + useEffect(() => { + cameraRef.current = camera; + }, [camera]); + + // Screen to world conversion + const screenToWorld = useCallback((screenX: number, screenY: number): { x: number; y: number } => { + const canvas = canvasRef.current; + if (!canvas) return { x: 0, y: 0 }; + + const rect = canvas.getBoundingClientRect(); + const dpr = window.devicePixelRatio || 1; + + // Convert to canvas pixel coordinates + const canvasX = (screenX - rect.left) * dpr; + const canvasY = (screenY - rect.top) * dpr; + + // Convert to centered coordinates (Y-up) + const centeredX = canvasX - canvas.width / 2; + const centeredY = canvas.height / 2 - canvasY; + + // Apply inverse zoom and add camera position + const cam = cameraRef.current; + const worldX = centeredX / cam.zoom + cam.x; + const worldY = centeredY / cam.zoom + cam.y; + + return { x: worldX, y: worldY }; + }, []); + + // World to screen conversion + const worldToScreen = useCallback((worldX: number, worldY: number): { x: number; y: number } => { + const canvas = canvasRef.current; + if (!canvas) return { x: 0, y: 0 }; + + const rect = canvas.getBoundingClientRect(); + const dpr = window.devicePixelRatio || 1; + const cam = cameraRef.current; + + // Apply camera transform + const centeredX = (worldX - cam.x) * cam.zoom; + const centeredY = (worldY - cam.y) * cam.zoom; + + // Convert from centered coordinates + const canvasX = centeredX + canvas.width / 2; + const canvasY = canvas.height / 2 - centeredY; + + // Convert to screen coordinates + const screenX = canvasX / dpr + rect.left; + const screenY = canvasY / dpr + rect.top; + + return { x: screenX, y: screenY }; + }, []); + + // Request render + const requestRender = useCallback(() => { + const viewportService = ViewportService.getInstance(); + if (viewportService.isInitialized()) { + viewportService.renderToViewport(viewportId); + } + }, [viewportId]); + + // Expose imperative handle + useImperativeHandle(ref, () => ({ + getCamera: () => cameraRef.current, + setCamera: (newCamera: ViewportCameraConfig) => { + setCamera(newCamera); + onCameraChange?.(newCamera); + }, + resetCamera: () => { + setCamera(initialCamera); + onCameraChange?.(initialCamera); + }, + screenToWorld, + worldToScreen, + getCanvas: () => canvasRef.current, + requestRender + }), [initialCamera, screenToWorld, worldToScreen, onCameraChange, requestRender]); + + // Initialize viewport + useEffect(() => { + const canvas = canvasRef.current; + const container = containerRef.current; + if (!canvas || !container) return; + + const canvasId = `editor-viewport-canvas-${viewportId}`; + canvas.id = canvasId; + + const viewportService = ViewportService.getInstance(); + + // Wait for service to be initialized + const checkInit = () => { + if (viewportService.isInitialized()) { + // Register viewport + viewportService.registerViewport(viewportId, canvasId); + viewportService.setViewportConfig(viewportId, showGrid, showGizmos); + viewportService.setViewportCamera(viewportId, camera); + + setIsReady(true); + onReady?.(); + } else { + // Retry after a short delay + setTimeout(checkInit, 100); + } + }; + + checkInit(); + + return () => { + if (viewportService.isInitialized()) { + viewportService.unregisterViewport(viewportId); + } + }; + }, [viewportId]); + + // Update viewport config when props change + useEffect(() => { + if (!isReady) return; + + const viewportService = ViewportService.getInstance(); + if (viewportService.isInitialized()) { + viewportService.setViewportConfig(viewportId, showGrid, showGizmos); + } + }, [viewportId, showGrid, showGizmos, isReady]); + + // Sync camera to viewport service + useEffect(() => { + if (!isReady) return; + + const viewportService = ViewportService.getInstance(); + if (viewportService.isInitialized()) { + viewportService.setViewportCamera(viewportId, camera); + } + }, [viewportId, camera, isReady]); + + // Handle resize + useEffect(() => { + const canvas = canvasRef.current; + const container = containerRef.current; + if (!canvas || !container) return; + + const resizeCanvas = () => { + const rect = container.getBoundingClientRect(); + const dpr = window.devicePixelRatio || 1; + + canvas.width = rect.width * dpr; + canvas.height = rect.height * dpr; + canvas.style.width = `${rect.width}px`; + canvas.style.height = `${rect.height}px`; + + if (isReady) { + const viewportService = ViewportService.getInstance(); + if (viewportService.isInitialized()) { + viewportService.resizeViewport(viewportId, canvas.width, canvas.height); + } + } + }; + + resizeCanvas(); + + let rafId: number | null = null; + const resizeObserver = new ResizeObserver(() => { + if (rafId !== null) { + cancelAnimationFrame(rafId); + } + rafId = requestAnimationFrame(() => { + resizeCanvas(); + rafId = null; + }); + }); + + resizeObserver.observe(container); + + return () => { + if (rafId !== null) { + cancelAnimationFrame(rafId); + } + resizeObserver.disconnect(); + }; + }, [viewportId, isReady]); + + // Mouse handlers + const handleMouseDown = useCallback((e: React.MouseEvent) => { + const worldPos = screenToWorld(e.clientX, e.clientY); + + // Middle or right button for camera pan + if (enablePan && (e.button === 1 || e.button === 2)) { + isDraggingRef.current = true; + lastMousePosRef.current = { x: e.clientX, y: e.clientY }; + e.preventDefault(); + } + + onMouseDown?.(e, worldPos); + }, [enablePan, screenToWorld, onMouseDown]); + + const handleMouseMove = useCallback((e: React.MouseEvent) => { + const worldPos = screenToWorld(e.clientX, e.clientY); + + if (isDraggingRef.current && enablePan) { + const deltaX = e.clientX - lastMousePosRef.current.x; + const deltaY = e.clientY - lastMousePosRef.current.y; + const dpr = window.devicePixelRatio || 1; + + setCamera(prev => { + const newCamera = { + ...prev, + x: prev.x - (deltaX * dpr) / prev.zoom, + y: prev.y + (deltaY * dpr) / prev.zoom + }; + onCameraChange?.(newCamera); + return newCamera; + }); + + lastMousePosRef.current = { x: e.clientX, y: e.clientY }; + } + + onMouseMove?.(e, worldPos); + }, [enablePan, screenToWorld, onMouseMove, onCameraChange]); + + const handleMouseUp = useCallback((e: React.MouseEvent) => { + const worldPos = screenToWorld(e.clientX, e.clientY); + isDraggingRef.current = false; + onMouseUp?.(e, worldPos); + }, [screenToWorld, onMouseUp]); + + const handleWheel = useCallback((e: React.WheelEvent) => { + const worldPos = screenToWorld(e.clientX, e.clientY); + + if (enableZoom) { + e.preventDefault(); + const zoomFactor = e.deltaY > 0 ? 0.9 : 1.1; + + setCamera(prev => { + const newZoom = Math.max(minZoom, Math.min(maxZoom, prev.zoom * zoomFactor)); + const newCamera = { ...prev, zoom: newZoom }; + onCameraChange?.(newCamera); + return newCamera; + }); + } + + onWheel?.(e, worldPos); + }, [enableZoom, minZoom, maxZoom, screenToWorld, onWheel, onCameraChange]); + + const handleContextMenu = useCallback((e: React.MouseEvent) => { + e.preventDefault(); + }, []); + + return ( +
+ + {renderOverlays?.()} +
+ ); +}); + +export default EditorViewport; diff --git a/packages/editor-app/src/components/Inspector.tsx b/packages/editor-app/src/components/Inspector.tsx deleted file mode 100644 index edbe9c3a..00000000 --- a/packages/editor-app/src/components/Inspector.tsx +++ /dev/null @@ -1,1112 +0,0 @@ -import { useState, useEffect, useRef } from 'react'; -import { Entity } from '@esengine/ecs-framework'; -import { EntityStoreService, MessageHub, InspectorRegistry, InspectorContext } from '@esengine/editor-core'; -import { PropertyInspector } from './PropertyInspector'; -import { FileSearch, ChevronDown, ChevronRight, X, Settings, File as FileIcon, Folder, Clock, HardDrive, Tag, Layers, ArrowUpDown, GitBranch, Activity, AlertTriangle, RefreshCw, Image as ImageIcon } from 'lucide-react'; -import { TauriAPI } from '../api/tauri'; -import { useToast } from './Toast'; -import { SettingsService } from '../services/SettingsService'; -import { convertFileSrc } from '@tauri-apps/api/core'; -import '../styles/EntityInspector.css'; - -interface InspectorProps { - entityStore: EntityStoreService; - messageHub: MessageHub; - inspectorRegistry: InspectorRegistry; - projectPath?: string | null; -} - -function getProfilerService(): any { - return (window as any).__PROFILER_SERVICE__; -} - -function formatNumber(value: number, decimalPlaces: number): string { - if (decimalPlaces < 0) { - return String(value); - } - if (Number.isInteger(value)) { - return String(value); - } - return value.toFixed(decimalPlaces); -} - -interface AssetFileInfo { - name: string; - path: string; - extension?: string; - size?: number; - modified?: number; - isDirectory: boolean; -} - -type InspectorTarget = - | { type: 'entity'; data: Entity } - | { type: 'remote-entity'; data: any; details?: any } - | { type: 'asset-file'; data: AssetFileInfo; content?: string; isImage?: boolean } - | { type: 'extension'; data: unknown } - | null; - -export function Inspector({ entityStore: _entityStore, messageHub, inspectorRegistry, projectPath }: InspectorProps) { - const [target, setTarget] = useState(null); - const [expandedComponents, setExpandedComponents] = useState>(new Set()); - const [componentVersion, setComponentVersion] = useState(0); - const [autoRefresh, setAutoRefresh] = useState(true); - const [decimalPlaces, setDecimalPlaces] = useState(() => { - const settings = SettingsService.getInstance(); - return settings.get('inspector.decimalPlaces', 4); - }); - const { showToast } = useToast(); - const targetRef = useRef(null); - - useEffect(() => { - targetRef.current = target; - }, [target]); - - useEffect(() => { - const handleSettingsChanged = (event: Event) => { - const customEvent = event as CustomEvent; - const changedSettings = customEvent.detail; - if ('inspector.decimalPlaces' in changedSettings) { - setDecimalPlaces(changedSettings['inspector.decimalPlaces']); - } - }; - - window.addEventListener('settings:changed', handleSettingsChanged); - return () => { - window.removeEventListener('settings:changed', handleSettingsChanged); - }; - }, []); - - useEffect(() => { - const handleEntitySelection = (data: { entity: Entity | null }) => { - if (data.entity) { - setTarget({ type: 'entity', data: data.entity }); - } else { - setTarget(null); - } - setComponentVersion(0); - }; - - const handleRemoteEntitySelection = (data: { entity: any }) => { - setTarget({ type: 'remote-entity', data: data.entity }); - const profilerService = getProfilerService(); - if (profilerService && data.entity?.id !== undefined) { - profilerService.requestEntityDetails(data.entity.id); - } - }; - - const handleEntityDetails = (event: Event) => { - const customEvent = event as CustomEvent; - const details = customEvent.detail; - const currentTarget = targetRef.current; - if (currentTarget?.type === 'remote-entity' && details?.id === currentTarget.data.id) { - setTarget({ ...currentTarget, details }); - } - }; - - const handleExtensionSelection = (data: { data: unknown }) => { - setTarget({ type: 'extension', data: data.data }); - }; - - const handleAssetFileSelection = async (data: { fileInfo: AssetFileInfo }) => { - const fileInfo = data.fileInfo; - - if (fileInfo.isDirectory) { - setTarget({ type: 'asset-file', data: fileInfo }); - return; - } - - const textExtensions = ['txt', 'json', 'md', 'ts', 'tsx', 'js', 'jsx', 'css', 'html', 'xml', 'yaml', 'yml', 'toml', 'ini', 'cfg', 'conf', 'log', 'btree', 'ecs']; - const imageExtensions = ['png', 'jpg', 'jpeg', 'gif', 'bmp', 'webp', 'svg', 'ico', 'tiff', 'tif']; - const isTextFile = fileInfo.extension && textExtensions.includes(fileInfo.extension.toLowerCase()); - const isImageFile = fileInfo.extension && imageExtensions.includes(fileInfo.extension.toLowerCase()); - - if (isTextFile) { - try { - const content = await TauriAPI.readFileContent(fileInfo.path); - setTarget({ type: 'asset-file', data: fileInfo, content }); - } catch (error) { - console.error('Failed to read file content:', error); - setTarget({ type: 'asset-file', data: fileInfo }); - } - } else if (isImageFile) { - setTarget({ type: 'asset-file', data: fileInfo, isImage: true }); - } else { - setTarget({ type: 'asset-file', data: fileInfo }); - } - }; - - const handleComponentChange = () => { - setComponentVersion((prev) => prev + 1); - }; - - const unsubEntitySelect = messageHub.subscribe('entity:selected', handleEntitySelection); - const unsubRemoteSelect = messageHub.subscribe('remote-entity:selected', handleRemoteEntitySelection); - const unsubNodeSelect = messageHub.subscribe('behavior-tree:node-selected', handleExtensionSelection); - const unsubAssetFileSelect = messageHub.subscribe('asset-file:selected', handleAssetFileSelection); - const unsubComponentAdded = messageHub.subscribe('component:added', handleComponentChange); - const unsubComponentRemoved = messageHub.subscribe('component:removed', handleComponentChange); - - window.addEventListener('profiler:entity-details', handleEntityDetails); - - return () => { - unsubEntitySelect(); - unsubRemoteSelect(); - unsubNodeSelect(); - unsubAssetFileSelect(); - unsubComponentAdded(); - unsubComponentRemoved(); - window.removeEventListener('profiler:entity-details', handleEntityDetails); - }; - }, [messageHub]); - - useEffect(() => { - if (!autoRefresh || target?.type !== 'remote-entity') { - return; - } - - const profilerService = getProfilerService(); - if (!profilerService) { - return; - } - - const handleProfilerData = () => { - const currentTarget = targetRef.current; - if (currentTarget?.type === 'remote-entity' && currentTarget.data?.id !== undefined) { - profilerService.requestEntityDetails(currentTarget.data.id); - } - }; - - const unsubscribe = profilerService.subscribe(handleProfilerData); - - return () => { - unsubscribe(); - }; - }, [autoRefresh, target?.type]); - - const handleRemoveComponent = (index: number) => { - if (target?.type !== 'entity') return; - const entity = target.data; - const component = entity.components[index]; - if (component) { - entity.removeComponent(component); - messageHub.publish('component:removed', { entity, component }); - } - }; - - const toggleComponentExpanded = (index: number) => { - setExpandedComponents((prev) => { - const newSet = new Set(prev); - if (newSet.has(index)) { - newSet.delete(index); - } else { - newSet.add(index); - } - return newSet; - }); - }; - - const handlePropertyChange = (component: any, propertyName: string, value: any) => { - if (target?.type !== 'entity') return; - const entity = target.data; - messageHub.publish('component:property:changed', { - entity, - component, - propertyName, - value - }); - // Also publish scene:modified so other panels can react to changes - messageHub.publish('scene:modified', {}); - }; - - const renderRemoteProperty = (key: string, value: any) => { - if (value === null || value === undefined) { - return ( -
- - null -
- ); - } - - if (Array.isArray(value)) { - const getItemDisplay = (item: any): string => { - if (typeof item !== 'object' || item === null) { - return String(item); - } - if (item.typeName) return String(item.typeName); - const constructorName = item.constructor?.name; - if (constructorName && constructorName !== 'Object') { - return constructorName; - } - if (item.type) return String(item.type); - if (item.name) return String(item.name); - if (item.className) return String(item.className); - if (item._type) return String(item._type); - const keys = Object.keys(item); - if (keys.length <= 3) { - return `{${keys.join(', ')}}`; - } - return `{${keys.slice(0, 3).join(', ')}...}`; - }; - - // 检查是否是组件数组(有typeName和properties) - const isComponentArray = value.length > 0 && value[0]?.typeName && value[0]?.properties; - - if (isComponentArray) { - return ( -
- {value.map((item, index) => ( - - ))} -
- ); - } - - // 检查是否是字符串数组(如componentTypes) - const isStringArray = value.length > 0 && value.every((item: any) => typeof item === 'string'); - - if (isStringArray) { - return ( -
- -
- {value.map((item: string, index: number) => ( - - {item} - - ))} -
-
- ); - } - - return ( -
- -
- {value.length === 0 ? ( - Empty - ) : ( - value.map((item, index) => ( -
- [{index}]: - - {getItemDisplay(item)} - -
- )) - )} -
-
- ); - } - - if (typeof value === 'object' && !Array.isArray(value)) { - return ( -
- -
- {Object.entries(value).map(([subKey, subValue]) => ( -
- {subKey}: - - {typeof subValue === 'object' && subValue !== null - ? Array.isArray(subValue) - ? `Array(${subValue.length})` - : subValue.constructor?.name || JSON.stringify(subValue) - : String(subValue)} - -
- ))} -
-
- ); - } - - return ( -
- - {String(value)} -
- ); - }; - - const formatFileSize = (bytes?: number): string => { - if (!bytes) return '0 B'; - const units = ['B', 'KB', 'MB', 'GB']; - let size = bytes; - let unitIndex = 0; - while (size >= 1024 && unitIndex < units.length - 1) { - size /= 1024; - unitIndex++; - } - return `${size.toFixed(2)} ${units[unitIndex]}`; - }; - - const formatDate = (timestamp?: number): string => { - if (!timestamp) return '未知'; - const date = new Date(timestamp * 1000); - return date.toLocaleString('zh-CN', { - year: 'numeric', - month: '2-digit', - day: '2-digit', - hour: '2-digit', - minute: '2-digit' - }); - }; - - const renderAssetFile = (fileInfo: AssetFileInfo, content?: string, isImage?: boolean) => { - const IconComponent = fileInfo.isDirectory ? Folder : isImage ? ImageIcon : FileIcon; - const iconColor = fileInfo.isDirectory ? '#dcb67a' : isImage ? '#a78bfa' : '#90caf9'; - - return ( -
-
- - {fileInfo.name} -
- -
-
-
文件信息
-
- - - {fileInfo.isDirectory ? '文件夹' : fileInfo.extension ? `.${fileInfo.extension}` : '文件'} - -
- {fileInfo.size !== undefined && !fileInfo.isDirectory && ( -
- - {formatFileSize(fileInfo.size)} -
- )} - {fileInfo.modified !== undefined && ( -
- - {formatDate(fileInfo.modified)} -
- )} -
- - - {fileInfo.path} - -
-
- - {isImage && ( -
-
图片预览
- -
- )} - - {content && ( -
-
文件预览
-
- {content} -
-
- )} - - {!content && !isImage && !fileInfo.isDirectory && ( -
-
- 此文件类型不支持预览 -
-
- )} -
-
- ); - }; - - if (!target) { - return ( -
-
- -
未选择对象
-
- 选择实体或节点以查看详细信息 -
-
-
- ); - } - - if (target.type === 'extension') { - const context: InspectorContext = { - target: target.data, - projectPath, - readonly: false - }; - - const extensionContent = inspectorRegistry.render(target.data, context); - if (extensionContent) { - return extensionContent; - } - - return ( -
-
- -
未找到合适的检视器
-
- 此对象类型未注册检视器扩展 -
-
-
- ); - } - - if (target.type === 'asset-file') { - return renderAssetFile(target.data, target.content, target.isImage); - } - - if (target.type === 'remote-entity') { - const entity = target.data; - const details = (target as any).details; - - const handleManualRefresh = () => { - const profilerService = getProfilerService(); - if (profilerService && entity?.id !== undefined) { - profilerService.requestEntityDetails(entity.id); - } - }; - - return ( -
-
- - 运行时实体 #{entity.id} - {entity.destroyed && ( - 已销毁 - )} -
- - -
-
- -
-
-
基本信息
-
- - {entity.id} -
- {entity.name && ( -
- - {entity.name} -
- )} -
- - - {entity.enabled ? 'true' : 'false'} - -
- {entity.tag !== undefined && entity.tag !== 0 && ( -
- - - {entity.tag} - -
- )} -
- - {(entity.depth !== undefined || entity.updateOrder !== undefined || entity.parentId !== undefined || entity.childCount !== undefined) && ( -
-
层级信息
- {entity.depth !== undefined && ( -
- - {entity.depth} -
- )} - {entity.updateOrder !== undefined && ( -
- - {entity.updateOrder} -
- )} - {entity.parentId !== undefined && ( -
- - - {entity.parentId === null ? '无' : entity.parentId} - -
- )} - {entity.childCount !== undefined && ( -
- - {entity.childCount} -
- )} - {entity.activeInHierarchy !== undefined && ( -
- - - {entity.activeInHierarchy ? 'true' : 'false'} - -
- )} -
- )} - - {entity.componentMask !== undefined && ( -
-
调试信息
-
- - - {entity.componentMask} - -
-
- )} - - {details && details.components && Array.isArray(details.components) && details.components.length > 0 && ( -
-
组件 ({details.components.length})
- {details.components.map((comp: any, index: number) => ( - - ))} -
- )} - - {details && Object.entries(details).filter(([key]) => key !== 'components' && key !== 'componentTypes').length > 0 && ( -
-
其他信息
- {Object.entries(details) - .filter(([key]) => key !== 'components' && key !== 'componentTypes') - .map(([key, value]) => renderRemoteProperty(key, value))} -
- )} -
-
- ); - } - - if (target.type === 'entity') { - const entity = target.data; - - return ( -
-
- - {entity.name || `Entity #${entity.id}`} -
- -
-
-
基本信息
-
- - {entity.id} -
-
- - {entity.enabled ? 'true' : 'false'} -
-
- - {entity.components.length > 0 && ( -
-
组件
- {entity.components.map((component: any, index: number) => { - const isExpanded = expandedComponents.has(index); - const componentName = component.constructor?.name || 'Component'; - - return ( -
-
toggleComponentExpanded(index)} - style={{ - display: 'flex', - alignItems: 'center', - padding: '6px 8px', - backgroundColor: '#3a3a3a', - cursor: 'pointer', - userSelect: 'none', - borderBottom: isExpanded ? '1px solid #4a4a4a' : 'none' - }} - > - {isExpanded ? : } - - {componentName} - - -
- - {isExpanded && ( -
- handlePropertyChange(component, propName, value)} - /> -
- )} -
- ); - })} -
- )} -
-
- ); - } - - return null; -} - -interface ComponentItemProps { - component: { - typeName: string; - properties: Record; - }; - decimalPlaces?: number; -} - -function ComponentItem({ component, decimalPlaces = 4 }: ComponentItemProps) { - const [isExpanded, setIsExpanded] = useState(false); - - return ( -
-
setIsExpanded(!isExpanded)} - style={{ - display: 'flex', - alignItems: 'center', - padding: '6px 8px', - backgroundColor: '#3a3a3a', - cursor: 'pointer', - userSelect: 'none', - borderBottom: isExpanded ? '1px solid #4a4a4a' : 'none' - }} - > - {isExpanded ? : } - - {component.typeName} - -
- - {isExpanded && ( -
- {Object.entries(component.properties).map(([propName, propValue]) => ( - - ))} -
- )} -
- ); -} - -interface PropertyValueRendererProps { - name: string; - value: any; - depth: number; - decimalPlaces?: number; -} - -function PropertyValueRenderer({ name, value, depth, decimalPlaces = 4 }: PropertyValueRendererProps) { - const [isExpanded, setIsExpanded] = useState(false); - - const isExpandable = value !== null && typeof value === 'object' && !Array.isArray(value) && Object.keys(value).length > 0; - const isArray = Array.isArray(value); - - const renderSimpleValue = (val: any): string => { - if (val === null || val === undefined) return 'null'; - if (typeof val === 'boolean') return val ? 'true' : 'false'; - if (typeof val === 'number') return formatNumber(val, decimalPlaces); - if (typeof val === 'string') return val.length > 50 ? val.substring(0, 50) + '...' : val; - if (Array.isArray(val)) return `Array(${val.length})`; - if (typeof val === 'object') { - const keys = Object.keys(val); - if (keys.length === 0) return '{}'; - if (keys.length <= 2) { - const preview = keys.map((k) => `${k}: ${typeof val[k] === 'object' ? '...' : (typeof val[k] === 'number' ? formatNumber(val[k], decimalPlaces) : val[k])}`).join(', '); - return `{${preview}}`; - } - return `{${keys.slice(0, 2).join(', ')}...}`; - } - return String(val); - }; - - if (isExpandable) { - return ( -
0 ? '12px' : 0 }}> -
setIsExpanded(!isExpanded)} - style={{ - display: 'flex', - alignItems: 'center', - padding: '3px 0', - fontSize: '11px', - borderBottom: '1px solid #333', - cursor: 'pointer', - userSelect: 'none' - }} - > - {isExpanded ? : } - {name} - {!isExpanded && ( - - {renderSimpleValue(value)} - - )} -
- {isExpanded && ( -
- {Object.entries(value).map(([key, val]) => ( - - ))} -
- )} -
- ); - } - - if (isArray && value.length > 0) { - return ( -
0 ? '12px' : 0 }}> -
setIsExpanded(!isExpanded)} - style={{ - display: 'flex', - alignItems: 'center', - padding: '3px 0', - fontSize: '11px', - borderBottom: '1px solid #333', - cursor: 'pointer', - userSelect: 'none' - }} - > - {isExpanded ? : } - {name} - - Array({value.length}) - -
- {isExpanded && ( -
- {value.map((item: any, index: number) => ( - - ))} -
- )} -
- ); - } - - return ( -
0 ? '12px' : 0 - }} - > - {name} - - {renderSimpleValue(value)} - -
- ); -} - -interface ImagePreviewProps { - src: string; - alt: string; -} - -function ImagePreview({ src, alt }: ImagePreviewProps) { - const [scale, setScale] = useState(1); - const [position, setPosition] = useState({ x: 0, y: 0 }); - const [isDragging, setIsDragging] = useState(false); - const [dragStart, setDragStart] = useState({ x: 0, y: 0 }); - const [imageError, setImageError] = useState(false); - const containerRef = useRef(null); - - const handleWheel = (e: React.WheelEvent) => { - e.preventDefault(); - const delta = e.deltaY > 0 ? 0.9 : 1.1; - setScale((prev) => Math.min(Math.max(prev * delta, 0.1), 10)); - }; - - const handleMouseDown = (e: React.MouseEvent) => { - if (e.button === 0) { - setIsDragging(true); - setDragStart({ x: e.clientX - position.x, y: e.clientY - position.y }); - } - }; - - const handleMouseMove = (e: React.MouseEvent) => { - if (isDragging) { - setPosition({ - x: e.clientX - dragStart.x, - y: e.clientY - dragStart.y - }); - } - }; - - const handleMouseUp = () => { - setIsDragging(false); - }; - - const handleReset = () => { - setScale(1); - setPosition({ x: 0, y: 0 }); - }; - - if (imageError) { - return ( -
- 图片加载失败 -
- ); - } - - return ( -
-
- {alt} setImageError(true)} - /> -
-
- 缩放: {(scale * 100).toFixed(0)}% - -
-
- ); -} diff --git a/packages/editor-app/src/components/MenuBar.tsx b/packages/editor-app/src/components/MenuBar.tsx index 48d8a1cc..ce2f7c0a 100644 --- a/packages/editor-app/src/components/MenuBar.tsx +++ b/packages/editor-app/src/components/MenuBar.tsx @@ -34,6 +34,7 @@ interface MenuBarProps { onOpenAbout?: () => void; onCreatePlugin?: () => void; onReloadPlugins?: () => void; + onOpenBuildSettings?: () => void; } export function MenuBar({ @@ -55,7 +56,8 @@ export function MenuBar({ onToggleDevtools, onOpenAbout, onCreatePlugin, - onReloadPlugins + onReloadPlugins, + onOpenBuildSettings }: MenuBarProps) { const [openMenu, setOpenMenu] = useState(null); const [pluginMenuItems, setPluginMenuItems] = useState([]); @@ -129,7 +131,8 @@ export function MenuBar({ documentation: 'Documentation', checkForUpdates: 'Check for Updates', about: 'About', - devtools: 'Developer Tools' + devtools: 'Developer Tools', + buildSettings: 'Build Settings' }, zh: { file: '文件', @@ -164,7 +167,8 @@ export function MenuBar({ documentation: '文档', checkForUpdates: '检查更新', about: '关于', - devtools: '开发者工具' + devtools: '开发者工具', + buildSettings: '构建设置' } }; return translations[locale]?.[key] || key; @@ -178,6 +182,8 @@ export function MenuBar({ { label: t('saveScene'), shortcut: 'Ctrl+S', onClick: onSaveScene }, { label: t('saveSceneAs'), shortcut: 'Ctrl+Shift+S', onClick: onSaveSceneAs }, { separator: true }, + { label: t('buildSettings'), shortcut: 'Ctrl+Shift+B', onClick: onOpenBuildSettings }, + { separator: true }, { label: t('openProject'), onClick: onOpenProject }, { label: t('closeProject'), onClick: onCloseProject }, { separator: true }, diff --git a/packages/editor-app/src/components/ModuleListSetting.tsx b/packages/editor-app/src/components/ModuleListSetting.tsx new file mode 100644 index 00000000..d08e89a2 --- /dev/null +++ b/packages/editor-app/src/components/ModuleListSetting.tsx @@ -0,0 +1,305 @@ +/** + * Module List Setting Component. + * 模块列表设置组件。 + * + * Renders a list of engine modules with checkboxes to enable/disable. + * 渲染引擎模块列表,带复选框以启用/禁用。 + */ + +import React, { useState, useEffect, useCallback, useMemo } from 'react'; +import { ChevronDown, ChevronRight, Package, AlertCircle } from 'lucide-react'; +import type { ModuleManifest, ModuleCategory } from '@esengine/editor-core'; +import './styles/ModuleListSetting.css'; + +/** + * Module entry with enabled state. + * 带启用状态的模块条目。 + */ +interface ModuleEntry extends ModuleManifest { + enabled: boolean; + canDisable: boolean; + disableReason?: string; +} + +/** + * Props for ModuleListSetting. + */ +interface ModuleListSettingProps { + /** Module manifests (static) | 模块清单列表(静态) */ + modules?: ModuleManifest[]; + /** Function to get modules dynamically (sizes from module.json) | 动态获取模块的函数(大小来自 module.json) */ + getModules?: () => ModuleManifest[]; + /** + * Module IDs list. Meaning depends on useBlacklist. + * 模块 ID 列表。含义取决于 useBlacklist。 + * - useBlacklist=false: enabled modules (whitelist) + * - useBlacklist=true: disabled modules (blacklist) + */ + value: string[]; + /** Callback when modules change | 模块变更回调 */ + onModulesChange: (moduleIds: string[]) => void; + /** + * Use blacklist mode: value contains disabled modules instead of enabled. + * 使用黑名单模式:value 包含禁用的模块而不是启用的。 + * Default: false (whitelist mode) + */ + useBlacklist?: boolean; + /** Validate if module can be disabled | 验证模块是否可禁用 */ + validateDisable?: (moduleId: string) => Promise<{ canDisable: boolean; reason?: string }>; +} + +/** + * Format bytes to human readable string. + */ +function formatBytes(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} + +/** + * Module List Setting Component. + * 模块列表设置组件。 + */ +export const ModuleListSetting: React.FC = ({ + modules: staticModules, + getModules, + value, + onModulesChange, + useBlacklist = false, + validateDisable +}) => { + const [expandedCategories, setExpandedCategories] = useState>(new Set(['Core', 'Rendering'])); + const [validationError, setValidationError] = useState<{ moduleId: string; message: string } | null>(null); + const [loading, setLoading] = useState(null); + + // Get modules from function or static prop + // 从函数或静态 prop 获取模块 + const modules = useMemo(() => { + if (getModules) { + return getModules(); + } + return staticModules || []; + }, [getModules, staticModules]); + + // Build module entries with enabled state | 构建带启用状态的模块条目 + // In blacklist mode: enabled = NOT in value list + // In whitelist mode: enabled = IN value list + const moduleEntries: ModuleEntry[] = useMemo(() => { + return modules.map(mod => { + let enabled: boolean; + if (mod.isCore) { + enabled = true; // Core modules always enabled + } else if (useBlacklist) { + enabled = !value.includes(mod.id); // Blacklist: NOT in list = enabled + } else { + enabled = value.includes(mod.id); // Whitelist: IN list = enabled + } + return { + ...mod, + enabled, + canDisable: !mod.isCore, + disableReason: mod.isCore ? 'Core module cannot be disabled' : undefined + }; + }); + }, [modules, value, useBlacklist]); + + // Group by category | 按分类分组 + const groupedModules = useMemo(() => { + const groups = new Map(); + const categoryOrder: ModuleCategory[] = ['Core', 'Rendering', 'Physics', 'AI', 'Audio', 'Networking', 'Other']; + + // Initialize groups | 初始化分组 + for (const cat of categoryOrder) { + groups.set(cat, []); + } + + // Group modules | 分组模块 + for (const mod of moduleEntries) { + const cat = mod.category || 'Other'; + if (!groups.has(cat)) { + groups.set(cat, []); + } + groups.get(cat)!.push(mod); + } + + // Filter empty groups | 过滤空分组 + const result = new Map(); + for (const [cat, mods] of groups) { + if (mods.length > 0) { + result.set(cat, mods); + } + } + + return result; + }, [moduleEntries]); + + // Calculate total size (JS + WASM) | 计算总大小(JS + WASM) + const { totalJsSize, totalWasmSize, totalSize } = useMemo(() => { + let js = 0; + let wasm = 0; + for (const m of moduleEntries) { + if (m.enabled) { + js += m.jsSize || 0; + wasm += m.wasmSize || 0; + } + } + return { totalJsSize: js, totalWasmSize: wasm, totalSize: js + wasm }; + }, [moduleEntries]); + + // Toggle category expansion | 切换分类展开 + const toggleCategory = useCallback((category: string) => { + setExpandedCategories(prev => { + const next = new Set(prev); + if (next.has(category)) { + next.delete(category); + } else { + next.add(category); + } + return next; + }); + }, []); + + // Handle module toggle | 处理模块切换 + const handleModuleToggle = useCallback(async (module: ModuleEntry, enabled: boolean) => { + if (module.isCore) return; + + // If disabling, validate first | 如果禁用,先验证 + if (!enabled && validateDisable) { + setLoading(module.id); + try { + const result = await validateDisable(module.id); + if (!result.canDisable) { + setValidationError({ + moduleId: module.id, + message: result.reason || `Cannot disable ${module.displayName}` + }); + setLoading(null); + return; + } + } finally { + setLoading(null); + } + } + + // Update module list based on mode + let newValue: string[]; + + if (useBlacklist) { + // Blacklist mode: value contains disabled modules + if (enabled) { + // Remove from blacklist (and also remove dependencies) + const toRemove = new Set([module.id]); + // Also enable dependencies if they were disabled + for (const depId of module.dependencies) { + toRemove.add(depId); + } + newValue = value.filter(id => !toRemove.has(id)); + } else { + // Add to blacklist + newValue = [...value, module.id]; + } + } else { + // Whitelist mode: value contains enabled modules + if (enabled) { + // Add to whitelist (and dependencies) + newValue = [...value]; + const toEnable = [module.id, ...module.dependencies]; + for (const id of toEnable) { + if (!newValue.includes(id)) { + newValue.push(id); + } + } + } else { + // Remove from whitelist + newValue = value.filter(id => id !== module.id); + } + } + + onModulesChange(newValue); + }, [value, useBlacklist, onModulesChange, validateDisable]); + + return ( +
+ {/* Module categories | 模块分类 */} +
+ {Array.from(groupedModules.entries()).map(([category, mods]) => ( +
+
toggleCategory(category)} + > + {expandedCategories.has(category) ? ( + + ) : ( + + )} + {category} + + {mods.filter(m => m.enabled).length}/{mods.length} + +
+ + {expandedCategories.has(category) && ( +
+ {mods.map(mod => ( +
+ + {(mod.jsSize || mod.wasmSize) ? ( + + {mod.isCore ? '' : '+'} + {formatBytes((mod.jsSize || 0) + (mod.wasmSize || 0))} + {(mod.wasmSize ?? 0) > 0 && ( + + )} + + ) : null} +
+ ))} +
+ )} +
+ ))} +
+ + {/* Size footer | 大小页脚 */} +
+ Runtime size: + + {formatBytes(totalSize)} + {totalWasmSize > 0 && ( + + (JS: {formatBytes(totalJsSize)} + WASM: {formatBytes(totalWasmSize)}) + + )} + +
+ + {/* Validation error toast | 验证错误提示 */} + {validationError && ( +
+ + {validationError.message} + +
+ )} +
+ ); +}; + +export default ModuleListSetting; diff --git a/packages/editor-app/src/components/PluginListSetting.tsx b/packages/editor-app/src/components/PluginListSetting.tsx index 5b8d3ccc..88fdb055 100644 --- a/packages/editor-app/src/components/PluginListSetting.tsx +++ b/packages/editor-app/src/components/PluginListSetting.tsx @@ -11,7 +11,7 @@ import { useState, useEffect } from 'react'; import { Core } from '@esengine/ecs-framework'; -import { PluginManager, type RegisteredPlugin, type PluginCategory, ProjectService } from '@esengine/editor-core'; +import { PluginManager, type RegisteredPlugin, type ModuleCategory, ProjectService } from '@esengine/editor-core'; import { Check, Lock, Package } from 'lucide-react'; import { NotificationService } from '../services/NotificationService'; import '../styles/PluginListSetting.css'; @@ -20,21 +20,17 @@ interface PluginListSettingProps { pluginManager: PluginManager; } -const categoryLabels: Record = { - core: { zh: '核心', en: 'Core' }, - rendering: { zh: '渲染', en: 'Rendering' }, - ui: { zh: 'UI', en: 'UI' }, - ai: { zh: 'AI', en: 'AI' }, - physics: { zh: '物理', en: 'Physics' }, - audio: { zh: '音频', en: 'Audio' }, - networking: { zh: '网络', en: 'Networking' }, - tools: { zh: '工具', en: 'Tools' }, - scripting: { zh: '脚本', en: 'Scripting' }, - content: { zh: '内容', en: 'Content' }, - tilemap: { zh: '瓦片地图', en: 'Tilemap' } +const categoryLabels: Record = { + Core: { zh: '核心', en: 'Core' }, + Rendering: { zh: '渲染', en: 'Rendering' }, + Physics: { zh: '物理', en: 'Physics' }, + AI: { zh: 'AI', en: 'AI' }, + Audio: { zh: '音频', en: 'Audio' }, + Networking: { zh: '网络', en: 'Networking' }, + Other: { zh: '其他', en: 'Other' } }; -const categoryOrder: PluginCategory[] = ['core', 'rendering', 'ui', 'ai', 'scripting', 'physics', 'audio', 'networking', 'tilemap', 'tools', 'content']; +const categoryOrder: ModuleCategory[] = ['Core', 'Rendering', 'Physics', 'AI', 'Audio', 'Networking', 'Other']; export function PluginListSetting({ pluginManager }: PluginListSettingProps) { const [plugins, setPlugins] = useState([]); @@ -56,13 +52,13 @@ export function PluginListSetting({ pluginManager }: PluginListSettingProps) { }; const handleToggle = async (pluginId: string) => { - const plugin = plugins.find(p => p.plugin.descriptor.id === pluginId); + const plugin = plugins.find(p => p.plugin.manifest.id === pluginId); if (!plugin) return; - const descriptor = plugin.plugin.descriptor; + const manifest = plugin.plugin.manifest; // 核心插件不可禁用 - if (descriptor.isCore) { + if (manifest.isCore) { showWarning('核心插件不可禁用'); return; } @@ -71,14 +67,14 @@ export function PluginListSetting({ pluginManager }: PluginListSettingProps) { // 检查依赖(启用时) if (newEnabled) { - const deps = descriptor.dependencies || []; - const missingDeps = deps.filter(dep => { - const depPlugin = plugins.find(p => p.plugin.descriptor.id === dep.id); + const deps = manifest.dependencies || []; + const missingDeps = deps.filter((depId: string) => { + const depPlugin = plugins.find(p => p.plugin.manifest.id === depId); return depPlugin && !depPlugin.enabled; }); if (missingDeps.length > 0) { - showWarning(`需要先启用依赖插件: ${missingDeps.map(d => d.id).join(', ')}`); + showWarning(`需要先启用依赖插件: ${missingDeps.join(', ')}`); return; } } @@ -100,7 +96,7 @@ export function PluginListSetting({ pluginManager }: PluginListSettingProps) { // 更新本地状态 setPlugins(plugins.map(p => { - if (p.plugin.descriptor.id === pluginId) { + if (p.plugin.manifest.id === pluginId) { return { ...p, enabled: newEnabled }; } return p; @@ -115,7 +111,7 @@ export function PluginListSetting({ pluginManager }: PluginListSettingProps) { const notificationService = Core.services.tryResolve(NotificationService) as NotificationService | null; if (notificationService) { notificationService.show( - newEnabled ? `已启用插件: ${descriptor.name}` : `已禁用插件: ${descriptor.name}`, + newEnabled ? `已启用插件: ${manifest.displayName}` : `已禁用插件: ${manifest.displayName}`, 'success', 2000 ); @@ -135,8 +131,8 @@ export function PluginListSetting({ pluginManager }: PluginListSettingProps) { // 获取当前启用的插件列表(排除核心插件) const enabledPlugins = pluginManager.getEnabledPlugins() - .filter(p => !p.plugin.descriptor.isCore) - .map(p => p.plugin.descriptor.id); + .filter(p => !p.plugin.manifest.isCore) + .map(p => p.plugin.manifest.id); console.log('[PluginListSetting] Saving enabled plugins:', enabledPlugins); @@ -150,13 +146,13 @@ export function PluginListSetting({ pluginManager }: PluginListSettingProps) { // 按类别分组并排序 const groupedPlugins = plugins.reduce((acc, plugin) => { - const category = plugin.plugin.descriptor.category; + const category = plugin.plugin.manifest.category; if (!acc[category]) { acc[category] = []; } acc[category].push(plugin); return acc; - }, {} as Record); + }, {} as Record); // 按照 categoryOrder 排序 const sortedCategories = categoryOrder.filter(cat => groupedPlugins[cat]?.length > 0); @@ -169,19 +165,19 @@ export function PluginListSetting({ pluginManager }: PluginListSettingProps) { {categoryLabels[category]?.zh || category}
- {groupedPlugins[category].map(plugin => { - const descriptor = plugin.plugin.descriptor; + {groupedPlugins[category]?.map(plugin => { + const manifest = plugin.plugin.manifest; const hasRuntime = !!plugin.plugin.runtimeModule; const hasEditor = !!plugin.plugin.editorModule; return (
handleToggle(descriptor.id)} + key={manifest.id} + className={`plugin-item ${plugin.enabled ? 'enabled' : ''} ${manifest.isCore ? 'core' : ''}`} + onClick={() => handleToggle(manifest.id)} >
- {descriptor.isCore ? ( + {manifest.isCore ? ( ) : ( plugin.enabled && @@ -189,12 +185,12 @@ export function PluginListSetting({ pluginManager }: PluginListSettingProps) {
- {descriptor.name} - v{descriptor.version} + {manifest.displayName} + v{manifest.version}
- {descriptor.description && ( + {manifest.description && (
- {descriptor.description} + {manifest.description}
)}
diff --git a/packages/editor-app/src/components/SceneHierarchy.tsx b/packages/editor-app/src/components/SceneHierarchy.tsx index 706acd00..b8525cf9 100644 --- a/packages/editor-app/src/components/SceneHierarchy.tsx +++ b/packages/editor-app/src/components/SceneHierarchy.tsx @@ -928,11 +928,27 @@ function ContextMenuWithSubmenu({ 'other': { zh: '其他', en: 'Other' }, }; + // 实体创建模板的 label 本地化映射 + const entityTemplateLabels: Record = { + 'Sprite': { zh: '精灵', en: 'Sprite' }, + 'Animated Sprite': { zh: '动画精灵', en: 'Animated Sprite' }, + '创建 Tilemap': { zh: '瓦片地图', en: 'Tilemap' }, + 'Camera 2D': { zh: '2D 相机', en: 'Camera 2D' }, + }; + const getCategoryLabel = (category: string) => { const labels = categoryLabels[category]; return labels ? (locale === 'zh' ? labels.zh : labels.en) : category; }; + const getEntityTemplateLabel = (label: string) => { + const mapping = entityTemplateLabels[label]; + if (mapping) { + return locale === 'zh' ? mapping.zh : mapping.en; + } + return label; + }; + const templatesByCategory = pluginTemplates.reduce((acc, template) => { const cat = template.category || 'other'; if (!acc[cat]) acc[cat] = []; @@ -996,7 +1012,7 @@ function ContextMenuWithSubmenu({ {templates.map((template) => ( ))}
diff --git a/packages/editor-app/src/components/SettingsWindow.tsx b/packages/editor-app/src/components/SettingsWindow.tsx index 3e5a5739..ea08a9c4 100644 --- a/packages/editor-app/src/components/SettingsWindow.tsx +++ b/packages/editor-app/src/components/SettingsWindow.tsx @@ -13,8 +13,9 @@ import { } from 'lucide-react'; import { Core } from '@esengine/ecs-framework'; import { SettingsService } from '../services/SettingsService'; -import { SettingsRegistry, SettingCategory, SettingDescriptor, ProjectService, PluginManager, IPluginManager } from '@esengine/editor-core'; +import { SettingsRegistry, SettingCategory, SettingDescriptor, ProjectService, PluginManager, IPluginManager, ModuleManifest } from '@esengine/editor-core'; import { PluginListSetting } from './PluginListSetting'; +import { ModuleListSetting } from './ModuleListSetting'; import '../styles/SettingsWindow.css'; interface SettingsWindowProps { @@ -142,6 +143,9 @@ export function SettingsWindow({ onClose, settingsRegistry, initialCategoryId }: } else if (key === 'project.uiDesignResolution.preset') { const resolution = projectService.getUIDesignResolution(); initialValues.set(key, `${resolution.width}x${resolution.height}`); + } else if (key === 'project.disabledModules') { + // Load disabled modules from ProjectService + initialValues.set(key, projectService.getDisabledModules()); } else { initialValues.set(key, descriptor.defaultValue); } @@ -199,6 +203,8 @@ export function SettingsWindow({ onClose, settingsRegistry, initialCategoryId }: let uiResolutionChanged = false; let newWidth = 1920; let newHeight = 1080; + let disabledModulesChanged = false; + let newDisabledModules: string[] = []; for (const [key, value] of values.entries()) { if (key.startsWith('project.') && projectService) { @@ -215,6 +221,9 @@ export function SettingsWindow({ onClose, settingsRegistry, initialCategoryId }: newHeight = h; uiResolutionChanged = true; } + } else if (key === 'project.disabledModules') { + newDisabledModules = value as string[]; + disabledModulesChanged = true; } changedSettings[key] = value; } else { @@ -227,6 +236,10 @@ export function SettingsWindow({ onClose, settingsRegistry, initialCategoryId }: await projectService.setUIDesignResolution({ width: newWidth, height: newHeight }); } + if (disabledModulesChanged && projectService) { + await projectService.setDisabledModules(newDisabledModules); + } + console.log('[SettingsWindow] Saving settings, changedSettings:', changedSettings); window.dispatchEvent(new CustomEvent('settings:changed', { detail: changedSettings @@ -487,6 +500,31 @@ export function SettingsWindow({ onClose, settingsRegistry, initialCategoryId }: ); } + case 'moduleList': { + // Get module data from setting's custom props + // 从设置的自定义属性获取模块数据 + const moduleData = setting as SettingDescriptor & { + modules?: ModuleManifest[]; + getModules?: () => ModuleManifest[]; + useBlacklist?: boolean; + validateDisable?: (moduleId: string) => Promise<{ canDisable: boolean; reason?: string }>; + }; + const moduleValue = value as string[] || []; + + return ( +
+ handleValueChange(setting.key, newValue, setting)} + useBlacklist={moduleData.useBlacklist} + validateDisable={moduleData.validateDisable} + /> +
+ ); + } + default: return null; } diff --git a/packages/editor-app/src/components/TitleBar.tsx b/packages/editor-app/src/components/TitleBar.tsx index 2c25d218..72772706 100644 --- a/packages/editor-app/src/components/TitleBar.tsx +++ b/packages/editor-app/src/components/TitleBar.tsx @@ -36,6 +36,7 @@ interface TitleBarProps { onOpenAbout?: () => void; onCreatePlugin?: () => void; onReloadPlugins?: () => void; + onOpenBuildSettings?: () => void; } export function TitleBar({ @@ -58,7 +59,8 @@ export function TitleBar({ onToggleDevtools, onOpenAbout, onCreatePlugin, - onReloadPlugins + onReloadPlugins, + onOpenBuildSettings }: TitleBarProps) { const [openMenu, setOpenMenu] = useState(null); const [pluginMenuItems, setPluginMenuItems] = useState([]); @@ -152,7 +154,8 @@ export function TitleBar({ documentation: 'Documentation', checkForUpdates: 'Check for Updates', about: 'About', - devtools: 'Developer Tools' + devtools: 'Developer Tools', + buildSettings: 'Build Settings' }, zh: { file: '文件', @@ -187,7 +190,8 @@ export function TitleBar({ documentation: '文档', checkForUpdates: '检查更新', about: '关于', - devtools: '开发者工具' + devtools: '开发者工具', + buildSettings: '构建设置' } }; return translations[locale]?.[key] || key; @@ -201,6 +205,8 @@ export function TitleBar({ { label: t('saveScene'), shortcut: 'Ctrl+S', onClick: onSaveScene }, { label: t('saveSceneAs'), shortcut: 'Ctrl+Shift+S', onClick: onSaveSceneAs }, { separator: true }, + { label: t('buildSettings'), shortcut: 'Ctrl+Shift+B', onClick: onOpenBuildSettings }, + { separator: true }, { label: t('openProject'), onClick: onOpenProject }, { label: t('closeProject'), onClick: onCloseProject }, { separator: true }, diff --git a/packages/editor-app/src/components/Viewport.tsx b/packages/editor-app/src/components/Viewport.tsx index 32c04d95..df32d238 100644 --- a/packages/editor-app/src/components/Viewport.tsx +++ b/packages/editor-app/src/components/Viewport.tsx @@ -17,69 +17,139 @@ import { open } from '@tauri-apps/plugin-shell'; import { RuntimeResolver } from '../services/RuntimeResolver'; import { QRCodeDialog } from './QRCodeDialog'; -// Generate runtime HTML for browser preview -function generateRuntimeHtml(): string { +import type { ModuleManifest } from '../services/RuntimeResolver'; + +/** + * Generate runtime HTML for browser preview using ES Modules with import maps + * 使用 ES 模块和 import maps 生成浏览器预览的运行时 HTML + * + * This matches the structure of published builds for consistency + * 这与发布构建的结构一致 + */ +function generateRuntimeHtml(importMap: Record, modules: ModuleManifest[]): string { + const importMapScript = ``; + + // Generate plugin import code for modules with pluginExport + // Only modules with pluginExport are considered runtime plugins + // Core/infrastructure modules don't need to be registered as plugins + const pluginModules = modules.filter(m => m.pluginExport); + + const pluginImportCode = pluginModules.map(m => + ` try { + const { ${m.pluginExport} } = await import('@esengine/${m.id}'); + runtime.registerPlugin(${m.pluginExport}); + } catch (e) { + console.warn('[Preview] Failed to load plugin ${m.id}:', e.message); + }` + ).join('\n'); + return ` - + ECS Runtime Preview +${importMapScript} - - +
+
+
Loading...
+
+
+

Failed to start

+

+    
+ + `; @@ -697,13 +767,13 @@ export function Viewport({ locale = 'en', messageHub }: ViewportProps) { await TauriAPI.createDirectory(runtimeDir); } - // Use RuntimeResolver to copy runtime files - // 使用 RuntimeResolver 复制运行时文件 + // Use RuntimeResolver to copy runtime files with ES Modules structure + // 使用 RuntimeResolver 复制运行时文件(ES 模块结构) const runtimeResolver = RuntimeResolver.getInstance(); await runtimeResolver.initialize(); - await runtimeResolver.prepareRuntimeFiles(runtimeDir); + const { modules, importMap } = await runtimeResolver.prepareRuntimeFiles(runtimeDir); - // Write scene data and HTML (always update) + // Write scene data await TauriAPI.writeFileContent(`${runtimeDir}/scene.json`, sceneData); // Copy project config file (for plugin settings) @@ -818,7 +888,8 @@ export function Viewport({ locale = 'en', messageHub }: ViewportProps) { await TauriAPI.writeFileContent(`${runtimeDir}/asset-catalog.json`, JSON.stringify(assetCatalog, null, 2)); console.log(`[Viewport] Asset catalog created with ${Object.keys(catalogEntries).length} entries`); - const runtimeHtml = generateRuntimeHtml(); + // Generate HTML with import maps (matching published build structure) + const runtimeHtml = generateRuntimeHtml(importMap, modules); await TauriAPI.writeFileContent(`${runtimeDir}/index.html`, runtimeHtml); // Start local server and open browser @@ -865,10 +936,10 @@ export function Viewport({ locale = 'en', messageHub }: ViewportProps) { await TauriAPI.createDirectory(runtimeDir); } - // Use RuntimeResolver to copy runtime files + // Use RuntimeResolver to copy runtime files with ES Modules structure const runtimeResolver = RuntimeResolver.getInstance(); await runtimeResolver.initialize(); - await runtimeResolver.prepareRuntimeFiles(runtimeDir); + const { modules, importMap } = await runtimeResolver.prepareRuntimeFiles(runtimeDir); // Copy project config file (for plugin settings) const projectService = Core.services.tryResolve(ProjectService); @@ -883,10 +954,10 @@ export function Viewport({ locale = 'en', messageHub }: ViewportProps) { } } - // Write scene data and HTML + // Write scene data and HTML with import maps const sceneDataStr = typeof sceneData === 'string' ? sceneData : new TextDecoder().decode(sceneData); await TauriAPI.writeFileContent(`${runtimeDir}/scene.json`, sceneDataStr); - await TauriAPI.writeFileContent(`${runtimeDir}/index.html`, generateRuntimeHtml()); + await TauriAPI.writeFileContent(`${runtimeDir}/index.html`, generateRuntimeHtml(importMap, modules)); // Copy textures referenced in scene const assetsDir = `${runtimeDir}\\assets`; diff --git a/packages/editor-app/src/components/inspectors/Inspector.tsx b/packages/editor-app/src/components/inspectors/Inspector.tsx index 75a3480e..6db4287c 100644 --- a/packages/editor-app/src/components/inspectors/Inspector.tsx +++ b/packages/editor-app/src/components/inspectors/Inspector.tsx @@ -99,7 +99,11 @@ export function Inspector({ entityStore: _entityStore, messageHub, inspectorRegi 'conf', 'log', 'btree', - 'ecs' + 'ecs', + 'mat', + 'shader', + 'tilemap', + 'tileset' ]; const imageExtensions = ['png', 'jpg', 'jpeg', 'gif', 'bmp', 'webp', 'svg', 'ico', 'tiff', 'tif']; const isTextFile = fileInfo.extension && textExtensions.includes(fileInfo.extension.toLowerCase()); @@ -188,6 +192,12 @@ export function Inspector({ entityStore: _entityStore, messageHub, inspectorRegi } if (target.type === 'asset-file') { + // Check if a plugin provides a custom inspector for this asset type + const customInspector = inspectorRegistry.render(target, { target, projectPath }); + if (customInspector) { + return customInspector; + } + // Fall back to default asset file inspector return ; } diff --git a/packages/editor-app/src/components/inspectors/views/EntityInspector.tsx b/packages/editor-app/src/components/inspectors/views/EntityInspector.tsx index 9a0a3903..b6b8e747 100644 --- a/packages/editor-app/src/components/inspectors/views/EntityInspector.tsx +++ b/packages/editor-app/src/components/inspectors/views/EntityInspector.tsx @@ -59,16 +59,26 @@ export function EntityInspector({ entity, messageHub, commandManager, componentV const availableComponents = (componentRegistry?.getAllComponents() || []) as ComponentInfo[]; // 当 entity 变化或组件数量变化时,更新展开状态(新组件默认展开) + // 注意:不要依赖 componentVersion,否则每次属性变化都会重置展开状态 useEffect(() => { setExpandedComponents((prev) => { const newSet = new Set(prev); - // 添加所有当前组件的索引(保留已有的展开状态) + // 只添加新增组件的索引(保留已有的展开/收缩状态) entity.components.forEach((_, index) => { - newSet.add(index); + // 只有当索引不在集合中时才添加(即新组件) + if (!prev.has(index) && index >= prev.size) { + newSet.add(index); + } }); + // 移除不存在的索引(组件被删除的情况) + for (const idx of prev) { + if (idx >= entity.components.length) { + newSet.delete(idx); + } + } return newSet; }); - }, [entity, entity.components.length, componentVersion]); + }, [entity, entity.components.length]); useEffect(() => { if (showComponentMenu && addButtonRef.current) { @@ -439,6 +449,15 @@ export function EntityInspector({ entity, messageHub, commandManager, componentV onAction={handlePropertyAction} /> } + {/* Append-mode inspectors (shown after default inspector) */} + {componentInspectorRegistry?.renderAppendInspectors({ + component, + entity, + version: componentVersion + localVersion, + onChange: (propName: string, value: unknown) => + handlePropertyChange(component, propName, value), + onAction: handlePropertyAction + })} {/* Dynamic component actions from plugins */} {componentActionRegistry?.getActionsForComponent(componentName).map((action) => { // 解析图标:支持字符串(Lucide 图标名)或 React 元素 diff --git a/packages/editor-app/src/components/styles/ModuleListSetting.css b/packages/editor-app/src/components/styles/ModuleListSetting.css new file mode 100644 index 00000000..f1b60f60 --- /dev/null +++ b/packages/editor-app/src/components/styles/ModuleListSetting.css @@ -0,0 +1,187 @@ +/** + * Module List Setting Styles. + * 模块列表设置样式。 + */ + +.module-list-setting { + display: flex; + flex-direction: column; + gap: 8px; +} + +.module-list-categories { + display: flex; + flex-direction: column; + gap: 4px; +} + +/* Category Group */ +.module-category-group { + border: 1px solid var(--border-color, #3a3a3a); + border-radius: 4px; + overflow: hidden; +} + +.module-category-header { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + background: var(--bg-tertiary, #2a2a2a); + cursor: pointer; + user-select: none; +} + +.module-category-header:hover { + background: var(--bg-hover, #333); +} + +.module-category-name { + flex: 1; + font-size: 12px; + font-weight: 600; + color: var(--text-primary, #eee); +} + +.module-category-count { + font-size: 11px; + color: var(--text-tertiary, #888); +} + +/* Category Items */ +.module-category-items { + display: flex; + flex-direction: column; +} + +.module-item { + display: flex; + align-items: center; + justify-content: space-between; + padding: 6px 12px 6px 28px; + border-top: 1px solid var(--border-color, #3a3a3a); + transition: background-color 0.15s; +} + +.module-item:hover { + background: var(--bg-hover, #333); +} + +.module-item.loading { + opacity: 0.6; + pointer-events: none; +} + +/* Checkbox Label */ +.module-checkbox-label { + display: flex; + align-items: center; + gap: 8px; + cursor: pointer; + flex: 1; +} + +.module-checkbox-label input[type="checkbox"] { + width: 14px; + height: 14px; + margin: 0; + cursor: pointer; +} + +.module-checkbox-label input[type="checkbox"]:disabled { + cursor: not-allowed; + opacity: 0.5; +} + +.module-icon { + color: var(--text-secondary, #aaa); +} + +.module-item.enabled .module-icon { + color: var(--accent-color, #4a9eff); +} + +.module-name { + font-size: 12px; + color: var(--text-primary, #eee); +} + +.module-badge { + padding: 1px 6px; + font-size: 9px; + font-weight: 600; + text-transform: uppercase; + border-radius: 3px; +} + +.module-badge.core { + background: rgba(74, 158, 255, 0.2); + color: var(--accent-color, #4a9eff); +} + +.module-size { + font-size: 10px; + color: var(--text-tertiary, #888); +} + +.module-size-inlined { + font-style: italic; + opacity: 0.6; +} + +.module-wasm-indicator { + margin-left: 2px; +} + +.module-size-breakdown { + margin-left: 4px; + opacity: 0.7; + font-size: 9px; +} + +/* Footer */ +.module-list-footer { + display: flex; + justify-content: space-between; + padding: 8px 12px; + background: var(--bg-tertiary, #2a2a2a); + border-radius: 4px; + font-size: 11px; +} + +.module-list-size-label { + color: var(--text-secondary, #aaa); +} + +.module-list-size-value { + color: var(--text-primary, #eee); + font-weight: 600; +} + +/* Validation Error */ +.module-validation-error { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + background: rgba(255, 107, 107, 0.15); + border: 1px solid rgba(255, 107, 107, 0.3); + border-radius: 4px; + color: #ff6b6b; + font-size: 12px; +} + +.module-validation-error button { + margin-left: auto; + padding: 2px 8px; + background: transparent; + border: 1px solid currentColor; + border-radius: 3px; + color: inherit; + font-size: 11px; + cursor: pointer; +} + +.module-validation-error button:hover { + background: rgba(255, 107, 107, 0.2); +} diff --git a/packages/editor-app/src/i18n/locales/en.json b/packages/editor-app/src/i18n/locales/en.json index 4e01e969..3c5bc108 100644 --- a/packages/editor-app/src/i18n/locales/en.json +++ b/packages/editor-app/src/i18n/locales/en.json @@ -50,7 +50,12 @@ "core": "Core", "rendering": "Rendering", "physics": "Physics", - "audio": "Audio" + "audio": "Audio", + "tilemap": "Tilemap" + }, + "material": { + "name": "Material", + "description": "Custom material and shader component" }, "transform": { "description": "Transform - Position, Rotation, Scale" @@ -76,5 +81,16 @@ "audioSource": { "description": "Audio Source" } + }, + "file": { + "create": { + "material": "Material", + "shader": "Shader" + } + }, + "entity": { + "create": { + "materialEntity": "Material Entity" + } } } diff --git a/packages/editor-app/src/i18n/locales/zh.json b/packages/editor-app/src/i18n/locales/zh.json index 515061f8..2e450d8a 100644 --- a/packages/editor-app/src/i18n/locales/zh.json +++ b/packages/editor-app/src/i18n/locales/zh.json @@ -50,7 +50,12 @@ "core": "基础", "rendering": "渲染", "physics": "物理", - "audio": "音频" + "audio": "音频", + "tilemap": "瓦片地图" + }, + "material": { + "name": "材质", + "description": "自定义材质和着色器组件" }, "transform": { "description": "变换组件 - 位置、旋转、缩放" @@ -76,5 +81,16 @@ "audioSource": { "description": "音频源组件" } + }, + "file": { + "create": { + "material": "材质", + "shader": "着色器" + } + }, + "entity": { + "create": { + "materialEntity": "材质实体" + } } } diff --git a/packages/editor-app/src/plugins/builtin/EditorAppearancePlugin.tsx b/packages/editor-app/src/plugins/builtin/EditorAppearancePlugin.tsx index da4ed07b..c949f8e7 100644 --- a/packages/editor-app/src/plugins/builtin/EditorAppearancePlugin.tsx +++ b/packages/editor-app/src/plugins/builtin/EditorAppearancePlugin.tsx @@ -5,7 +5,7 @@ import type { ServiceContainer } from '@esengine/ecs-framework'; import { createLogger } from '@esengine/ecs-framework'; -import type { IPlugin, IEditorModuleLoader, PluginDescriptor } from '@esengine/editor-core'; +import type { IPlugin, IEditorModuleLoader, ModuleManifest } from '@esengine/editor-core'; import { SettingsRegistry } from '@esengine/editor-core'; import { SettingsService } from '../../services/SettingsService'; @@ -102,27 +102,23 @@ class EditorAppearanceEditorModule implements IEditorModuleLoader { } } -const descriptor: PluginDescriptor = { +const manifest: ModuleManifest = { id: '@esengine/editor-appearance', - name: 'Editor Appearance', + name: '@esengine/editor-appearance', + displayName: 'Editor Appearance', version: '1.0.0', description: 'Configure editor appearance settings', - category: 'tools', + category: 'Other', icon: 'Palette', - enabledByDefault: true, - canContainContent: false, - isEnginePlugin: true, isCore: true, - modules: [ - { - name: 'EditorAppearanceEditor', - type: 'editor', - loadingPhase: 'earliest' - } - ] + defaultEnabled: true, + isEngineModule: true, + canContainContent: false, + dependencies: [], + exports: {} }; export const EditorAppearancePlugin: IPlugin = { - descriptor, + manifest, editorModule: new EditorAppearanceEditorModule() }; diff --git a/packages/editor-app/src/plugins/builtin/GizmoPlugin.ts b/packages/editor-app/src/plugins/builtin/GizmoPlugin.ts index 13184883..ad04090e 100644 --- a/packages/editor-app/src/plugins/builtin/GizmoPlugin.ts +++ b/packages/editor-app/src/plugins/builtin/GizmoPlugin.ts @@ -4,7 +4,7 @@ */ import type { ServiceContainer } from '@esengine/ecs-framework'; -import type { IPlugin, IEditorModuleLoader, PluginDescriptor, GizmoProviderRegistration } from '@esengine/editor-core'; +import type { IPlugin, IEditorModuleLoader, ModuleManifest, GizmoProviderRegistration } from '@esengine/editor-core'; import { registerSpriteGizmo } from '../../gizmos'; /** @@ -24,27 +24,25 @@ class GizmoEditorModule implements IEditorModuleLoader { } } -const descriptor: PluginDescriptor = { +const manifest: ModuleManifest = { id: '@esengine/gizmo', - name: 'Gizmo System', + name: '@esengine/gizmo', + displayName: 'Gizmo System', version: '1.0.0', description: 'Provides gizmo support for editor components', - category: 'tools', + category: 'Other', icon: 'Move', - enabledByDefault: true, - canContainContent: false, - isEnginePlugin: true, isCore: true, - modules: [ - { - name: 'GizmoEditor', - type: 'editor', - loadingPhase: 'preDefault' - } - ] + defaultEnabled: true, + isEngineModule: true, + canContainContent: false, + dependencies: ['engine-core'], + exports: { + other: ['GizmoRegistry'] + } }; export const GizmoPlugin: IPlugin = { - descriptor, + manifest, editorModule: new GizmoEditorModule() }; diff --git a/packages/editor-app/src/plugins/builtin/PluginConfigPlugin.tsx b/packages/editor-app/src/plugins/builtin/PluginConfigPlugin.tsx index 26e3c54c..317aef60 100644 --- a/packages/editor-app/src/plugins/builtin/PluginConfigPlugin.tsx +++ b/packages/editor-app/src/plugins/builtin/PluginConfigPlugin.tsx @@ -5,7 +5,7 @@ import type { ServiceContainer } from '@esengine/ecs-framework'; import { createLogger } from '@esengine/ecs-framework'; -import type { IPlugin, IEditorModuleLoader, PluginDescriptor } from '@esengine/editor-core'; +import type { IPlugin, IEditorModuleLoader, ModuleManifest } from '@esengine/editor-core'; import { SettingsRegistry } from '@esengine/editor-core'; const logger = createLogger('PluginConfigPlugin'); @@ -51,27 +51,23 @@ class PluginConfigEditorModule implements IEditorModuleLoader { } } -const descriptor: PluginDescriptor = { +const manifest: ModuleManifest = { id: '@esengine/plugin-config', - name: 'Plugin Config', + name: '@esengine/plugin-config', + displayName: 'Plugin Config', version: '1.0.0', description: 'Configure engine plugins', - category: 'tools', + category: 'Other', icon: 'Package', - enabledByDefault: true, - canContainContent: false, - isEnginePlugin: true, isCore: true, - modules: [ - { - name: 'PluginConfigEditor', - type: 'editor', - loadingPhase: 'postDefault' - } - ] + defaultEnabled: true, + isEngineModule: true, + canContainContent: false, + dependencies: [], + exports: {} }; export const PluginConfigPlugin: IPlugin = { - descriptor, + manifest, editorModule: new PluginConfigEditorModule() }; diff --git a/packages/editor-app/src/plugins/builtin/ProfilerPlugin.tsx b/packages/editor-app/src/plugins/builtin/ProfilerPlugin.tsx index 7bdbcc62..f411dfd5 100644 --- a/packages/editor-app/src/plugins/builtin/ProfilerPlugin.tsx +++ b/packages/editor-app/src/plugins/builtin/ProfilerPlugin.tsx @@ -7,7 +7,7 @@ import type { ServiceContainer } from '@esengine/ecs-framework'; import type { IPlugin, IEditorModuleLoader, - PluginDescriptor, + ModuleManifest, MenuItemDescriptor } from '@esengine/editor-core'; import { MessageHub, SettingsRegistry } from '@esengine/editor-core'; @@ -114,26 +114,23 @@ class ProfilerEditorModule implements IEditorModuleLoader { async onEditorReady(): Promise {} } -const descriptor: PluginDescriptor = { +const manifest: ModuleManifest = { id: '@esengine/profiler', - name: 'Performance Profiler', + name: '@esengine/profiler', + displayName: 'Performance Profiler', version: '1.0.0', description: 'Real-time performance monitoring for ECS systems', - category: 'tools', + category: 'Other', icon: 'BarChart3', - enabledByDefault: true, + isCore: false, + defaultEnabled: true, + isEngineModule: true, canContainContent: false, - isEnginePlugin: true, - modules: [ - { - name: 'ProfilerEditor', - type: 'editor', - loadingPhase: 'postDefault' - } - ] + dependencies: [], + exports: {} }; export const ProfilerPlugin: IPlugin = { - descriptor, + manifest, editorModule: new ProfilerEditorModule() }; diff --git a/packages/editor-app/src/plugins/builtin/ProjectSettingsPlugin.tsx b/packages/editor-app/src/plugins/builtin/ProjectSettingsPlugin.tsx index 424dc35c..6b0b2ebc 100644 --- a/packages/editor-app/src/plugins/builtin/ProjectSettingsPlugin.tsx +++ b/packages/editor-app/src/plugins/builtin/ProjectSettingsPlugin.tsx @@ -8,10 +8,25 @@ import type { ServiceContainer } from '@esengine/ecs-framework'; import { createLogger, Core } from '@esengine/ecs-framework'; -import type { IPlugin, IEditorModuleLoader, PluginDescriptor } from '@esengine/editor-core'; -import { SettingsRegistry, ProjectService } from '@esengine/editor-core'; +import type { IPlugin, IEditorModuleLoader, ModuleManifest } from '@esengine/editor-core'; +import { SettingsRegistry, ProjectService, moduleRegistry } from '@esengine/editor-core'; import EngineService from '../../services/EngineService'; +/** + * Get engine modules from ModuleRegistry. + * 从 ModuleRegistry 获取引擎模块。 + * + * Returns all registered modules from the module registry. + * 返回模块注册表中的所有已注册模块。 + */ +function getModuleManifests(): ModuleManifest[] { + // Get modules from moduleRegistry singleton + // 从 moduleRegistry 单例获取模块 + const modules = moduleRegistry.getAllModules(); + console.log('[ProjectSettingsPlugin] getModuleManifests: got', modules.length, 'modules from registry'); + return modules; +} + const logger = createLogger('ProjectSettingsPlugin'); /** @@ -85,6 +100,38 @@ class ProjectSettingsEditorModule implements IEditorModuleLoader { })) } ] + }, + { + id: 'modules', + title: '引擎模块', + description: '管理项目使用的引擎模块。每个模块包含运行时组件和编辑器工具。禁用不需要的模块可以减小构建体积。', + settings: [ + { + key: 'project.disabledModules', + label: '模块列表', + type: 'moduleList', + // Default: no modules disabled (all enabled) + // 默认:没有禁用的模块(全部启用) + defaultValue: [], + description: '取消勾选不需要的模块。核心模块不能禁用。新增的模块会自动启用。', + // Custom props for moduleList type + // Modules are loaded dynamically from ModuleRegistry (sizes from module.json) + // 模块从 ModuleRegistry 动态加载(大小来自 module.json) + getModules: getModuleManifests, + // Use blacklist mode: store disabled modules instead of enabled + // 使用黑名单模式:存储禁用的模块而不是启用的 + useBlacklist: true, + validateDisable: async (moduleId: string) => { + // Use moduleRegistry singleton for validation + // 使用 moduleRegistry 单例进行验证 + const validation = await moduleRegistry.validateDisable(moduleId); + if (!validation.canDisable) { + return { canDisable: false, reason: validation.message }; + } + return { canDisable: true }; + } + } as any // Cast to any to allow custom props + ] } ] }); @@ -147,27 +194,23 @@ class ProjectSettingsEditorModule implements IEditorModuleLoader { } } -const descriptor: PluginDescriptor = { +const manifest: ModuleManifest = { id: '@esengine/project-settings', - name: 'Project Settings', + name: '@esengine/project-settings', + displayName: 'Project Settings', version: '1.0.0', description: 'Configure project-level settings', - category: 'tools', + category: 'Other', icon: 'Settings', - enabledByDefault: true, - canContainContent: false, - isEnginePlugin: true, isCore: true, - modules: [ - { - name: 'ProjectSettingsEditor', - type: 'editor', - loadingPhase: 'postDefault' - } - ] + defaultEnabled: true, + isEngineModule: true, + canContainContent: false, + dependencies: [], + exports: {} }; export const ProjectSettingsPlugin: IPlugin = { - descriptor, + manifest, editorModule: new ProjectSettingsEditorModule() }; diff --git a/packages/editor-app/src/plugins/builtin/SceneInspectorPlugin.ts b/packages/editor-app/src/plugins/builtin/SceneInspectorPlugin.ts index 47fa3266..f41cabc2 100644 --- a/packages/editor-app/src/plugins/builtin/SceneInspectorPlugin.ts +++ b/packages/editor-app/src/plugins/builtin/SceneInspectorPlugin.ts @@ -8,7 +8,7 @@ import type { ServiceContainer } from '@esengine/ecs-framework'; import type { IPlugin, IEditorModuleLoader, - PluginDescriptor, + ModuleManifest, PanelDescriptor, MenuItemDescriptor, ToolbarItemDescriptor, @@ -173,27 +173,25 @@ class SceneInspectorEditorModule implements IEditorModuleLoader { async onProjectClose(): Promise {} } -const descriptor: PluginDescriptor = { +const manifest: ModuleManifest = { id: '@esengine/scene-inspector', - name: 'Scene Inspector', + name: '@esengine/scene-inspector', + displayName: 'Scene Inspector', version: '1.0.0', description: 'Scene hierarchy and entity inspector', - category: 'tools', + category: 'Other', icon: 'Search', - enabledByDefault: true, - canContainContent: false, - isEnginePlugin: true, isCore: true, - modules: [ - { - name: 'SceneInspectorEditor', - type: 'editor', - loadingPhase: 'default' - } - ] + defaultEnabled: true, + isEngineModule: true, + canContainContent: false, + dependencies: ['engine-core'], + exports: { + other: ['SceneHierarchy', 'EntityInspector'] + } }; export const SceneInspectorPlugin: IPlugin = { - descriptor, + manifest, editorModule: new SceneInspectorEditorModule() }; diff --git a/packages/editor-app/src/plugins/builtin/index.ts b/packages/editor-app/src/plugins/builtin/index.ts index 08b5b57a..a880462c 100644 --- a/packages/editor-app/src/plugins/builtin/index.ts +++ b/packages/editor-app/src/plugins/builtin/index.ts @@ -7,7 +7,7 @@ export { GizmoPlugin } from './GizmoPlugin'; export { SceneInspectorPlugin } from './SceneInspectorPlugin'; export { ProfilerPlugin } from './ProfilerPlugin'; export { EditorAppearancePlugin } from './EditorAppearancePlugin'; -export { PluginConfigPlugin } from './PluginConfigPlugin'; export { ProjectSettingsPlugin } from './ProjectSettingsPlugin'; +// Note: PluginConfigPlugin removed - module management is now unified in ProjectSettingsPlugin // TODO: Re-enable when blueprint-editor package is fixed // export { BlueprintPlugin } from '@esengine/blueprint-editor'; diff --git a/packages/editor-app/src/services/BuildFileSystemService.ts b/packages/editor-app/src/services/BuildFileSystemService.ts new file mode 100644 index 00000000..a7ec583d --- /dev/null +++ b/packages/editor-app/src/services/BuildFileSystemService.ts @@ -0,0 +1,243 @@ +/** + * Build File System Service. + * 构建文件系统服务。 + * + * Provides file operations for build pipelines via Tauri commands. + * 通过 Tauri 命令为构建管线提供文件操作。 + */ + +import { invoke } from '@tauri-apps/api/core'; + +/** + * Bundle options. + * 打包选项。 + */ +export interface BundleOptions { + /** Entry files | 入口文件 */ + entryPoints: string[]; + /** Output directory | 输出目录 */ + outputDir: string; + /** Output format (esm or iife) | 输出格式 */ + format: 'esm' | 'iife'; + /** Bundle name | 打包名称 */ + bundleName: string; + /** Whether to minify | 是否压缩 */ + minify: boolean; + /** Whether to generate source map | 是否生成 source map */ + sourceMap: boolean; + /** External dependencies | 外部依赖 */ + external: string[]; + /** Project root for resolving imports | 项目根目录 */ + projectRoot: string; + /** Define replacements | 宏定义替换 */ + define?: Record; +} + +/** + * Bundle result. + * 打包结果。 + */ +export interface BundleResult { + /** Whether bundling succeeded | 是否打包成功 */ + success: boolean; + /** Output file path | 输出文件路径 */ + outputFile?: string; + /** Output file size in bytes | 输出文件大小 */ + outputSize?: number; + /** Error message if failed | 失败时的错误信息 */ + error?: string; + /** Warnings | 警告 */ + warnings: string[]; +} + +/** + * Build File System Service. + * 构建文件系统服务。 + */ +export class BuildFileSystemService { + /** + * Prepare build directory (clean and recreate). + * 准备构建目录(清理并重建)。 + * + * @param outputPath - Output directory path | 输出目录路径 + */ + async prepareBuildDirectory(outputPath: string): Promise { + await invoke('prepare_build_directory', { outputPath }); + } + + /** + * Copy directory recursively. + * 递归复制目录。 + * + * @param src - Source directory | 源目录 + * @param dst - Destination directory | 目标目录 + * @param patterns - File patterns to include (e.g. ["*.png", "*.json"]) | 要包含的文件模式 + * @returns Number of files copied | 复制的文件数量 + */ + async copyDirectory( + src: string, + dst: string, + patterns?: string[] + ): Promise { + return await invoke('copy_directory', { src, dst, patterns }); + } + + /** + * Bundle scripts using esbuild. + * 使用 esbuild 打包脚本。 + * + * @param options - Bundle options | 打包选项 + * @returns Bundle result | 打包结果 + */ + async bundleScripts(options: BundleOptions): Promise { + return await invoke('bundle_scripts', { options }); + } + + /** + * Generate HTML file. + * 生成 HTML 文件。 + * + * @param outputPath - Output file path | 输出文件路径 + * @param title - Page title | 页面标题 + * @param scripts - Script paths to include | 要包含的脚本路径 + * @param bodyContent - Custom body content | 自定义 body 内容 + */ + async generateHtml( + outputPath: string, + title: string, + scripts: string[], + bodyContent?: string + ): Promise { + await invoke('generate_html', { outputPath, title, scripts, bodyContent }); + } + + /** + * Get file size. + * 获取文件大小。 + * + * @param filePath - File path | 文件路径 + * @returns File size in bytes | 文件大小(字节) + */ + async getFileSize(filePath: string): Promise { + return await invoke('get_file_size', { filePath }); + } + + /** + * Get directory size recursively. + * 递归获取目录大小。 + * + * @param dirPath - Directory path | 目录路径 + * @returns Total size in bytes | 总大小(字节) + */ + async getDirectorySize(dirPath: string): Promise { + return await invoke('get_directory_size', { dirPath }); + } + + /** + * Write JSON file. + * 写入 JSON 文件。 + * + * @param filePath - File path | 文件路径 + * @param content - JSON content as string | JSON 内容字符串 + */ + async writeJsonFile(filePath: string, content: string): Promise { + await invoke('write_json_file', { filePath, content }); + } + + /** + * List files by extension. + * 按扩展名列出文件。 + * + * @param dirPath - Directory path | 目录路径 + * @param extensions - File extensions (without dot) | 文件扩展名(不含点) + * @param recursive - Whether to search recursively | 是否递归搜索 + * @returns List of file paths | 文件路径列表 + */ + async listFilesByExtension( + dirPath: string, + extensions: string[], + recursive: boolean = true + ): Promise { + return await invoke('list_files_by_extension', { dirPath, extensions, recursive }); + } + + /** + * Copy single file. + * 复制单个文件。 + * + * @param src - Source file path | 源文件路径 + * @param dst - Destination file path | 目标文件路径 + */ + async copyFile(src: string, dst: string): Promise { + await invoke('copy_file', { src, dst }); + } + + /** + * Check if path exists. + * 检查路径是否存在。 + * + * @param path - Path to check | 要检查的路径 + * @returns Whether path exists | 路径是否存在 + */ + async pathExists(path: string): Promise { + return await invoke('path_exists', { path }); + } + + /** + * Read file content. + * 读取文件内容。 + * + * @param path - File path | 文件路径 + * @returns File content | 文件内容 + */ + async readFile(path: string): Promise { + return await invoke('read_file_content', { path }); + } + + /** + * Write file content. + * 写入文件内容。 + * + * @param path - File path | 文件路径 + * @param content - Content to write | 要写入的内容 + */ + async writeFile(path: string, content: string): Promise { + await invoke('write_file_content', { path, content }); + } + + /** + * Read JSON file. + * 读取 JSON 文件。 + * + * @param path - File path | 文件路径 + * @returns Parsed JSON object | 解析后的 JSON 对象 + */ + async readJson(path: string): Promise { + const content = await invoke('read_file_content', { path }); + return JSON.parse(content) as T; + } + + /** + * Create directory. + * 创建目录。 + * + * @param path - Directory path | 目录路径 + */ + async createDirectory(path: string): Promise { + await invoke('create_directory', { path }); + } + + /** + * Read binary file as base64. + * 读取二进制文件为 base64。 + * + * @param path - File path | 文件路径 + * @returns Base64 encoded content | Base64 编码的内容 + */ + async readBinaryFileAsBase64(path: string): Promise { + return await invoke('read_binary_file_as_base64', { path }); + } +} + +// Singleton instance | 单例实例 +export const buildFileSystem = new BuildFileSystemService(); diff --git a/packages/editor-app/src/services/EngineService.ts b/packages/editor-app/src/services/EngineService.ts index 72253a57..c89273d5 100644 --- a/packages/editor-app/src/services/EngineService.ts +++ b/packages/editor-app/src/services/EngineService.ts @@ -27,8 +27,10 @@ import { EditorPlatformAdapter, type GameRuntimeConfig } from '@esengine/runtime-core'; +import { getMaterialManager } from '@esengine/material-system'; import { convertFileSrc } from '@tauri-apps/api/core'; import { IdGenerator } from '../utils/idGenerator'; +import { TauriAssetReader } from './TauriAssetReader'; /** * Engine service singleton for editor integration. @@ -191,7 +193,7 @@ export class EngineService { // 创建系统上下文 const context: SystemContext = { - core: Core, + services: Core.services, engineBridge: this._runtime.bridge, renderSystem: this._runtime.renderSystem, assetManager: this._assetManager, @@ -345,11 +347,25 @@ export class EngineService { try { this._assetManager = new AssetManager(); + // Set up asset reader for Tauri environment. + // 为 Tauri 环境设置资产读取器。 + const assetReader = new TauriAssetReader(); + this._assetManager.setReader(assetReader); + + // Set project root when project is open. + // 当项目打开时设置项目根路径。 + const projectService = Core.services.tryResolve(ProjectService); + if (projectService && projectService.isProjectOpen()) { + const projectInfo = projectService.getCurrentProject(); + if (projectInfo) { + this._assetManager.setProjectRoot(projectInfo.path); + } + } + const pathTransformerFn = (path: string) => { if (!path.startsWith('http://') && !path.startsWith('https://') && !path.startsWith('data:') && !path.startsWith('asset://')) { if (!path.startsWith('/') && !path.match(/^[a-zA-Z]:/)) { - const projectService = Core.services.tryResolve(ProjectService); if (projectService && projectService.isProjectOpen()) { const projectInfo = projectService.getCurrentProject(); if (projectInfo) { @@ -386,6 +402,13 @@ export class EngineService { } } + // Set asset manager for MaterialManager. + // 为 MaterialManager 设置 asset manager。 + const materialManager = getMaterialManager(); + if (materialManager) { + materialManager.setAssetManager(this._assetManager); + } + this._assetSystemInitialized = true; this._initializationError = null; } catch (error) { diff --git a/packages/editor-app/src/services/PluginLoader.ts b/packages/editor-app/src/services/PluginLoader.ts index b961f9ea..87719631 100644 --- a/packages/editor-app/src/services/PluginLoader.ts +++ b/packages/editor-app/src/services/PluginLoader.ts @@ -4,7 +4,7 @@ */ import { PluginManager, LocaleService, MessageHub } from '@esengine/editor-core'; -import type { IPluginLoader, PluginDescriptor } from '@esengine/editor-core'; +import type { IPluginLoader, ModuleManifest } from '@esengine/editor-core'; import { Core } from '@esengine/ecs-framework'; import { TauriAPI } from '../api/tauri'; import { PluginSDKRegistry } from './PluginSDKRegistry'; @@ -132,7 +132,7 @@ export class PluginLoader { pluginManager.register(pluginLoader); // 8. 初始化编辑器模块(注册面板、文件处理器等) - const pluginId = pluginLoader.descriptor.id; + const pluginId = pluginLoader.manifest.id; await pluginManager.initializePluginEditor(pluginId, Core.services); // 9. 记录已加载 @@ -285,7 +285,7 @@ export class PluginLoader { } // 新的 IPluginLoader 接口检查 - if (obj.descriptor && this.isPluginDescriptor(obj.descriptor)) { + if (obj.manifest && this.isModuleManifest(obj.manifest)) { return true; } @@ -293,9 +293,9 @@ export class PluginLoader { } /** - * 验证对象是否为有效的插件描述符 + * 验证对象是否为有效的模块清单 */ - private isPluginDescriptor(obj: any): obj is PluginDescriptor { + private isModuleManifest(obj: any): obj is ModuleManifest { return ( obj && typeof obj.id === 'string' && diff --git a/packages/editor-app/src/services/RuntimeResolver.ts b/packages/editor-app/src/services/RuntimeResolver.ts index 6055b68f..e8376ebb 100644 --- a/packages/editor-app/src/services/RuntimeResolver.ts +++ b/packages/editor-app/src/services/RuntimeResolver.ts @@ -1,56 +1,32 @@ /** - * Runtime Module Resolver * 运行时模块解析器 - * - * Resolves runtime module paths based on environment and configuration - * 根据环境和配置解析运行时模块路径 - * - * 运行时文件打包在编辑器内,离线可用 + * Runtime Module Resolver */ import { TauriAPI } from '../api/tauri'; -// Sanitize path by removing path traversal sequences and normalizing -const sanitizePath = (path: string): string => { - // Split by path separators, filter out '..' and empty segments, rejoin - const segments = path.split(/[/\\]/).filter((segment) => - segment !== '..' && segment !== '.' && segment !== '' - ); - // Use Windows backslash for consistency - return segments.join('\\'); -}; - -// Check if we're in development mode -const isDevelopment = (): boolean => { - try { - // Vite environment variable - this is the most reliable check - const viteDev = (import.meta as any).env?.DEV === true; - // Also check if MODE is 'development' - const viteMode = (import.meta as any).env?.MODE === 'development'; - return viteDev || viteMode; - } catch { - return false; - } -}; - -export interface RuntimeModule { - type: 'javascript' | 'wasm' | 'binary'; - files: string[]; - sourcePath: string; -} - -export interface RuntimeConfig { - runtime: { - version: string; - modules: Record; - }; +/** + * 运行时模块清单 + * Module manifest for runtime modules + */ +export interface ModuleManifest { + id: string; + name: string; + version: string; + dependencies: string[]; + hasRuntime: boolean; + pluginExport?: string; + requiresWasm?: boolean; + wasmPaths?: string[]; + runtimeWasmPath?: string; + externalDependencies?: string[]; } export class RuntimeResolver { private static instance: RuntimeResolver; - private config: RuntimeConfig | null = null; private baseDir: string = ''; - private isDev: boolean = false; // Store dev mode state at initialization time + private engineModulesPath: string = ''; + private initialized: boolean = false; private constructor() {} @@ -62,67 +38,40 @@ export class RuntimeResolver { } /** - * Initialize the runtime resolver * 初始化运行时解析器 + * Initialize the runtime resolver */ async initialize(): Promise { - // Load runtime configuration - const response = await fetch('/runtime.config.json'); - if (!response.ok) { - throw new Error(`Failed to load runtime configuration: ${response.status} ${response.statusText}`); - } - const contentType = response.headers.get('content-type'); - if (!contentType || !contentType.includes('application/json')) { - throw new Error(`Invalid runtime configuration response type: ${contentType}. Expected JSON but received ${await response.text().then(t => t.substring(0, 100))}`); - } - this.config = await response.json(); + if (this.initialized) return; - // 查找 workspace 根目录 + // 查找工作区根目录 | Find workspace root const currentDir = await TauriAPI.getCurrentDir(); - const workspaceRoot = await this.findWorkspaceRoot(currentDir); + this.baseDir = await this.findWorkspaceRoot(currentDir); - // 优先使用 workspace 中的开发文件(如果存在) - // Prefer workspace dev files if they exist - if (await this.hasRuntimeFilesInWorkspace(workspaceRoot)) { - this.baseDir = workspaceRoot; - this.isDev = true; - } else { - // 回退到打包的资源目录(生产模式) - this.baseDir = await TauriAPI.getAppResourceDir(); - this.isDev = false; - } + // 查找引擎模块路径 | Find engine modules path + this.engineModulesPath = await this.findEngineModulesPath(); + + this.initialized = true; } /** - * Check if runtime files exist in workspace - * 检查 workspace 中是否存在运行时文件 - */ - private async hasRuntimeFilesInWorkspace(workspaceRoot: string): Promise { - const runtimePath = `${workspaceRoot}\\packages\\platform-web\\dist\\runtime.browser.js`; - return await TauriAPI.pathExists(runtimePath); - } - - /** - * Find workspace root by looking for package.json or specific markers - * 通过查找 package.json 或特定标记来找到工作区根目录 + * 查找工作区根目录 + * Find workspace root by looking for workspace markers */ private async findWorkspaceRoot(startPath: string): Promise { let currentPath = startPath; - // Try to find the workspace root by looking for key files - // We'll check up to 3 levels up from current directory - for (let i = 0; i < 3; i++) { - // Check if we're in src-tauri + for (let i = 0; i < 5; i++) { + // 检查是否在 src-tauri 目录 | Check if we're in src-tauri if (currentPath.endsWith('src-tauri')) { - // Go up two levels to get to workspace root const parts = currentPath.split(/[/\\]/); - parts.pop(); // Remove src-tauri - parts.pop(); // Remove editor-app - parts.pop(); // Remove packages + parts.pop(); + parts.pop(); + parts.pop(); return parts.join('\\'); } - // Check for workspace markers + // 检查工作区标记 | Check for workspace markers const workspaceMarkers = [ `${currentPath}\\pnpm-workspace.yaml`, `${currentPath}\\packages\\editor-app`, @@ -141,103 +90,336 @@ export class RuntimeResolver { currentPath = parts.join('\\'); } - // Fallback to current directory return startPath; } /** - * Get runtime module files - * 获取运行时模块文件 + * Find engine modules path (where compiled modules with module.json are) + * 查找引擎模块路径(编译后的模块和 module.json 所在位置) */ - async getModuleFiles(moduleName: string): Promise { - if (!this.config) { - await this.initialize(); + private async findEngineModulesPath(): Promise { + // Try installed editor location first + const installedPath = 'C:/Program Files/ESEngine Editor/engine'; + if (await TauriAPI.pathExists(`${installedPath}/index.json`)) { + return installedPath; } - const moduleConfig = this.config!.runtime.modules[moduleName]; - if (!moduleConfig) { - throw new Error(`Runtime module ${moduleName} not found in configuration`); + // Try workspace packages directory (dev mode) + const workspacePath = `${this.baseDir}\\packages`; + if (await TauriAPI.pathExists(`${workspacePath}\\core\\module.json`)) { + return workspacePath; } - const files: string[] = []; - let sourcePath: string; - - if (this.isDev) { - // Development mode - use relative paths from workspace root - const devPath = moduleConfig.development.path; - const sanitizedPath = sanitizePath(devPath); - sourcePath = `${this.baseDir}\\packages\\${sanitizedPath}`; - - if (moduleConfig.main) { - files.push(`${sourcePath}\\${moduleConfig.main}`); - } - if (moduleConfig.files) { - for (const file of moduleConfig.files) { - files.push(`${sourcePath}\\${file}`); - } - } - } else { - // Production mode - files are bundled with the app - sourcePath = this.baseDir; - - if (moduleConfig.main) { - files.push(`${sourcePath}\\${moduleConfig.main}`); - } - if (moduleConfig.files) { - for (const file of moduleConfig.files) { - files.push(`${sourcePath}\\${file}`); - } - } - } - - return { - type: moduleConfig.type, - files, - sourcePath - }; + return workspacePath; } /** - * Prepare runtime files for browser preview - * 为浏览器预览准备运行时文件 + * Get list of available runtime modules + * 获取可用的运行时模块列表 * - * 开发模式:从本地 workspace 复制 - * 生产模式:从编辑器内置资源复制 + * Scans the packages directory for module.json files instead of hardcoding + * 扫描 packages 目录查找 module.json 文件,而不是硬编码 */ - async prepareRuntimeFiles(targetDir: string): Promise { + async getAvailableModules(): Promise { + if (!this.initialized) { + await this.initialize(); + } + + const modules: ModuleManifest[] = []; + + // Try to read index.json if it exists (installed editor) + const indexPath = `${this.engineModulesPath}\\index.json`; + if (await TauriAPI.pathExists(indexPath)) { + try { + const indexContent = await TauriAPI.readFileContent(indexPath); + const indexData = JSON.parse(indexContent) as { modules: ModuleManifest[] }; + return indexData.modules.filter(m => m.hasRuntime); + } catch (e) { + console.warn('[RuntimeResolver] Failed to read index.json:', e); + } + } + + // Scan packages directory for module.json files + const packageEntries = await TauriAPI.listDirectory(this.engineModulesPath); + for (const entry of packageEntries) { + if (!entry.is_dir) continue; + + const manifestPath = `${this.engineModulesPath}\\${entry.name}\\module.json`; + if (await TauriAPI.pathExists(manifestPath)) { + try { + const content = await TauriAPI.readFileContent(manifestPath); + const manifest = JSON.parse(content) as ModuleManifest; + if (manifest.hasRuntime !== false) { + modules.push(manifest); + } + } catch (e) { + console.warn(`[RuntimeResolver] Failed to read module.json for ${entry.name}:`, e); + } + } + } + + // Sort by dependencies + return this.sortModulesByDependencies(modules); + } + + /** + * Sort modules by dependencies (topological sort) + * 按依赖排序模块(拓扑排序) + */ + private sortModulesByDependencies(modules: ModuleManifest[]): ModuleManifest[] { + const sorted: ModuleManifest[] = []; + const visited = new Set(); + const moduleMap = new Map(modules.map(m => [m.id, m])); + + const visit = (module: ModuleManifest) => { + if (visited.has(module.id)) return; + visited.add(module.id); + for (const depId of (module.dependencies || [])) { + const dep = moduleMap.get(depId); + if (dep) visit(dep); + } + sorted.push(module); + }; + + for (const module of modules) { + visit(module); + } + return sorted; + } + + /** + * Prepare runtime files for browser preview using ES Modules + * 使用 ES 模块为浏览器预览准备运行时文件 + * + * Creates libs/{moduleId}/{moduleId}.js structure matching published builds + * 创建与发布构建一致的 libs/{moduleId}/{moduleId}.js 结构 + */ + async prepareRuntimeFiles(targetDir: string): Promise<{ modules: ModuleManifest[], importMap: Record }> { + if (!this.initialized) { + await this.initialize(); + } + // Ensure target directory exists - const dirExists = await TauriAPI.pathExists(targetDir); - if (!dirExists) { + if (!await TauriAPI.pathExists(targetDir)) { await TauriAPI.createDirectory(targetDir); } - // Copy platform-web runtime - const platformWeb = await this.getModuleFiles('platform-web'); - for (const srcFile of platformWeb.files) { - const filename = srcFile.split(/[/\\]/).pop() || ''; - const dstFile = `${targetDir}\\${filename}`; + const libsDir = `${targetDir}\\libs`; + if (!await TauriAPI.pathExists(libsDir)) { + await TauriAPI.createDirectory(libsDir); + } - const srcExists = await TauriAPI.pathExists(srcFile); - if (srcExists) { + const modules = await this.getAvailableModules(); + const importMap: Record = {}; + const copiedModules: string[] = []; + + // Copy each module's dist files + for (const module of modules) { + const moduleDistDir = `${this.engineModulesPath}\\${module.id}\\dist`; + const moduleSrcFile = `${moduleDistDir}\\index.mjs`; + + // Check for index.mjs or index.js + let srcFile = moduleSrcFile; + if (!await TauriAPI.pathExists(srcFile)) { + srcFile = `${moduleDistDir}\\index.js`; + } + + if (await TauriAPI.pathExists(srcFile)) { + const dstModuleDir = `${libsDir}\\${module.id}`; + if (!await TauriAPI.pathExists(dstModuleDir)) { + await TauriAPI.createDirectory(dstModuleDir); + } + + const dstFile = `${dstModuleDir}\\${module.id}.js`; await TauriAPI.copyFile(srcFile, dstFile); - } else { - throw new Error(`Runtime file not found: ${srcFile}`); + + // Copy all chunk files (code splitting creates chunk-*.js files) + // 复制所有 chunk 文件(代码分割会创建 chunk-*.js 文件) + await this.copyChunkFiles(moduleDistDir, dstModuleDir); + + // Add to import map + importMap[`@esengine/${module.id}`] = `./libs/${module.id}/${module.id}.js`; + + // Also add common aliases + if (module.id === 'core') { + importMap['@esengine/ecs-framework'] = `./libs/${module.id}/${module.id}.js`; + } + if (module.id === 'math') { + importMap['@esengine/ecs-framework-math'] = `./libs/${module.id}/${module.id}.js`; + } + + copiedModules.push(module.id); } } - // Copy engine WASM files - const engine = await this.getModuleFiles('engine'); - for (const srcFile of engine.files) { - const filename = srcFile.split(/[/\\]/).pop() || ''; - const dstFile = `${targetDir}\\${filename}`; + // Copy external dependencies (e.g., rapier2d) + await this.copyExternalDependencies(modules, libsDir, importMap); - const srcExists = await TauriAPI.pathExists(srcFile); - if (srcExists) { - await TauriAPI.copyFile(srcFile, dstFile); - } else { - throw new Error(`Engine file not found: ${srcFile}`); + // Copy engine WASM files to libs/es-engine/ + await this.copyEngineWasm(libsDir); + + // Copy module-specific WASM files + await this.copyModuleWasm(modules, targetDir); + + console.log(`[RuntimeResolver] Prepared ${copiedModules.length} modules for browser preview`); + + return { modules, importMap }; + } + + /** + * Copy chunk files from dist directory (for code-split modules) + * 复制 dist 目录中的 chunk 文件(用于代码分割的模块) + */ + private async copyChunkFiles(srcDir: string, dstDir: string): Promise { + try { + const entries = await TauriAPI.listDirectory(srcDir); + for (const entry of entries) { + // Copy chunk-*.js files and any other .js files (except index.*) + if (!entry.is_dir && entry.name.endsWith('.js') && !entry.name.startsWith('index.')) { + const srcFile = `${srcDir}\\${entry.name}`; + const dstFile = `${dstDir}\\${entry.name}`; + await TauriAPI.copyFile(srcFile, dstFile); + } + } + } catch (e) { + // Ignore errors - some modules may not have chunk files + } + } + + /** + * Copy external dependencies like rapier2d + * 复制外部依赖如 rapier2d + */ + private async copyExternalDependencies( + modules: ModuleManifest[], + libsDir: string, + importMap: Record + ): Promise { + const externalDeps = new Set(); + for (const m of modules) { + if (m.externalDependencies) { + for (const dep of m.externalDependencies) { + externalDeps.add(dep); + } } } + + for (const dep of externalDeps) { + const depId = dep.startsWith('@esengine/') ? dep.slice(10) : dep.replace(/^@[^/]+\//, ''); + const srcDistDir = `${this.engineModulesPath}\\${depId}\\dist`; + let srcFile = `${srcDistDir}\\index.mjs`; + if (!await TauriAPI.pathExists(srcFile)) { + srcFile = `${srcDistDir}\\index.js`; + } + + if (await TauriAPI.pathExists(srcFile)) { + const dstModuleDir = `${libsDir}\\${depId}`; + if (!await TauriAPI.pathExists(dstModuleDir)) { + await TauriAPI.createDirectory(dstModuleDir); + } + + const dstFile = `${dstModuleDir}\\${depId}.js`; + await TauriAPI.copyFile(srcFile, dstFile); + + // Copy chunk files for external dependencies too + await this.copyChunkFiles(srcDistDir, dstModuleDir); + + importMap[dep] = `./libs/${depId}/${depId}.js`; + console.log(`[RuntimeResolver] Copied external dependency: ${depId}`); + } + } + } + + /** + * Copy engine WASM files + * 复制引擎 WASM 文件 + */ + private async copyEngineWasm(libsDir: string): Promise { + const esEngineDir = `${libsDir}\\es-engine`; + if (!await TauriAPI.pathExists(esEngineDir)) { + await TauriAPI.createDirectory(esEngineDir); + } + + // Try different locations for engine WASM + const wasmSearchPaths = [ + `${this.baseDir}\\packages\\engine\\pkg`, + `${this.engineModulesPath}\\..\\..\\engine\\pkg`, + 'C:/Program Files/ESEngine Editor/wasm' + ]; + + const filesToCopy = ['es_engine_bg.wasm', 'es_engine.js', 'es_engine_bg.js']; + + for (const searchPath of wasmSearchPaths) { + if (await TauriAPI.pathExists(searchPath)) { + for (const file of filesToCopy) { + const srcFile = `${searchPath}\\${file}`; + if (await TauriAPI.pathExists(srcFile)) { + const dstFile = `${esEngineDir}\\${file}`; + await TauriAPI.copyFile(srcFile, dstFile); + } + } + console.log('[RuntimeResolver] Copied engine WASM from:', searchPath); + return; + } + } + + console.warn('[RuntimeResolver] Engine WASM files not found'); + } + + /** + * Copy module-specific WASM files (e.g., physics) + * 复制模块特定的 WASM 文件(如物理) + */ + private async copyModuleWasm(modules: ModuleManifest[], targetDir: string): Promise { + for (const module of modules) { + if (!module.requiresWasm || !module.wasmPaths?.length) continue; + + const runtimePath = module.runtimeWasmPath || `wasm/${module.wasmPaths[0]}`; + const dstPath = `${targetDir}\\${runtimePath.replace(/\//g, '\\')}`; + const dstDir = dstPath.substring(0, dstPath.lastIndexOf('\\')); + + if (!await TauriAPI.pathExists(dstDir)) { + await TauriAPI.createDirectory(dstDir); + } + + // Search for the WASM file + const wasmPath = module.wasmPaths[0]; + if (!wasmPath) continue; + const wasmFileName = wasmPath.split(/[/\\]/).pop() || wasmPath; + + // Build search paths - check module's own pkg, external deps, and common locations + const searchPaths: string[] = [ + `${this.engineModulesPath}\\${module.id}\\pkg\\${wasmFileName}`, + `${this.baseDir}\\packages\\${module.id}\\pkg\\${wasmFileName}`, + ]; + + // Check external dependencies for WASM (e.g., physics-rapier2d uses rapier2d's WASM) + if (module.externalDependencies) { + for (const dep of module.externalDependencies) { + const depId = dep.startsWith('@esengine/') ? dep.slice(10) : dep.replace(/^@[^/]+\//, ''); + searchPaths.push(`${this.engineModulesPath}\\${depId}\\pkg\\${wasmFileName}`); + searchPaths.push(`${this.baseDir}\\packages\\${depId}\\pkg\\${wasmFileName}`); + } + } + + for (const srcPath of searchPaths) { + if (await TauriAPI.pathExists(srcPath)) { + await TauriAPI.copyFile(srcPath, dstPath); + console.log(`[RuntimeResolver] Copied ${module.id} WASM to ${runtimePath}`); + break; + } + } + } + } + + /** + * Generate import map for runtime HTML + * 生成运行时 HTML 的 import map + */ + generateImportMapHtml(importMap: Record): string { + return ``; } /** @@ -247,4 +429,12 @@ export class RuntimeResolver { getBaseDir(): string { return this.baseDir; } + + /** + * Get engine modules path + * 获取引擎模块路径 + */ + getEngineModulesPath(): string { + return this.engineModulesPath; + } } diff --git a/packages/editor-app/src/services/TauriAssetReader.ts b/packages/editor-app/src/services/TauriAssetReader.ts new file mode 100644 index 00000000..c6ff3909 --- /dev/null +++ b/packages/editor-app/src/services/TauriAssetReader.ts @@ -0,0 +1,80 @@ +/** + * Tauri Asset Reader + * Tauri 资产读取器 + * + * Implements IAssetReader for Tauri/editor environment. + * 为 Tauri/编辑器环境实现 IAssetReader。 + */ + +import { invoke } from '@tauri-apps/api/core'; +import { convertFileSrc } from '@tauri-apps/api/core'; +import type { IAssetReader } from '@esengine/asset-system'; + +/** + * Asset reader implementation for Tauri. + * Tauri 的资产读取器实现。 + */ +export class TauriAssetReader implements IAssetReader { + /** + * Read file as text. + * 读取文件为文本。 + */ + async readText(absolutePath: string): Promise { + return await invoke('read_file_content', { path: absolutePath }); + } + + /** + * Read file as binary. + * 读取文件为二进制。 + */ + async readBinary(absolutePath: string): Promise { + const bytes = await invoke('read_binary_file', { filePath: absolutePath }); + return new Uint8Array(bytes).buffer; + } + + /** + * Load image from file. + * 从文件加载图片。 + */ + async loadImage(absolutePath: string): Promise { + // Only convert if not already a URL. + // 仅当不是 URL 时才转换。 + let assetUrl = absolutePath; + if (!absolutePath.startsWith('http://') && + !absolutePath.startsWith('https://') && + !absolutePath.startsWith('data:') && + !absolutePath.startsWith('asset://')) { + assetUrl = convertFileSrc(absolutePath); + } + + return new Promise((resolve, reject) => { + const image = new Image(); + image.onload = () => resolve(image); + image.onerror = () => reject(new Error(`Failed to load image: ${absolutePath}`)); + image.src = assetUrl; + }); + } + + /** + * Load audio from file. + * 从文件加载音频。 + */ + async loadAudio(absolutePath: string): Promise { + const binary = await this.readBinary(absolutePath); + const audioContext = new AudioContext(); + return await audioContext.decodeAudioData(binary); + } + + /** + * Check if file exists. + * 检查文件是否存在。 + */ + async exists(absolutePath: string): Promise { + try { + await invoke('read_file_content', { path: absolutePath }); + return true; + } catch { + return false; + } + } +} diff --git a/packages/editor-app/src/services/TauriFileSystemService.ts b/packages/editor-app/src/services/TauriFileSystemService.ts index c79308e2..b886ba9b 100644 --- a/packages/editor-app/src/services/TauriFileSystemService.ts +++ b/packages/editor-app/src/services/TauriFileSystemService.ts @@ -51,7 +51,7 @@ export class TauriFileSystemService implements IFileSystem { } async scanFiles(basePath: string, pattern: string): Promise { - return await invoke('scan_files', { basePath, pattern }); + return await invoke('scan_directory', { path: basePath, pattern }); } convertToAssetUrl(filePath: string): string { diff --git a/packages/editor-app/src/services/TauriModuleFileSystem.ts b/packages/editor-app/src/services/TauriModuleFileSystem.ts new file mode 100644 index 00000000..bdc50d34 --- /dev/null +++ b/packages/editor-app/src/services/TauriModuleFileSystem.ts @@ -0,0 +1,143 @@ +/** + * Tauri Module File System + * Tauri 模块文件系统 + * + * Implements IModuleFileSystem interface for Tauri environment. + * 为 Tauri 环境实现 IModuleFileSystem 接口。 + * + * This reads module files via Tauri commands from the local file system. + * 通过 Tauri 命令从本地文件系统读取模块文件。 + */ + +import { invoke } from '@tauri-apps/api/core'; +import type { IModuleFileSystem } from '@esengine/editor-core'; + +/** + * Module index structure from Tauri backend. + * 来自 Tauri 后端的模块索引结构。 + */ +interface ModuleIndex { + version: string; + generatedAt: string; + modules: Array<{ + id: string; + name: string; + displayName: string; + hasRuntime: boolean; + editorPackage?: string; + isCore: boolean; + category: string; + }>; +} + +/** + * Tauri-based module file system for reading module manifests. + * 基于 Tauri 的模块文件系统,用于读取模块清单。 + */ +export class TauriModuleFileSystem implements IModuleFileSystem { + private _basePath: string = ''; + private _indexCache: ModuleIndex | null = null; + + /** + * Read JSON file via Tauri command. + * 通过 Tauri 命令读取 JSON 文件。 + */ + async readJson(path: string): Promise { + // Check if reading index.json + // 检查是否读取 index.json + if (path.endsWith('/index.json') || path === 'index.json') { + const index = await invoke('read_engine_modules_index'); + this._indexCache = index; + return index as unknown as T; + } + + // Extract module ID from path like "/engine/sprite/module.json" + // 从路径中提取模块 ID,如 "/engine/sprite/module.json" + const match = path.match(/\/([^/]+)\/module\.json$/); + if (match) { + const moduleId = match[1]; + return await invoke('read_module_manifest', { moduleId }); + } + + throw new Error(`Unsupported path: ${path}`); + } + + /** + * Write JSON file - not supported for engine modules. + * 写入 JSON 文件 - 引擎模块不支持。 + */ + async writeJson(_path: string, _data: unknown): Promise { + throw new Error('Write operation not supported for engine modules'); + } + + /** + * Check if path exists. + * 检查路径是否存在。 + */ + async pathExists(path: string): Promise { + try { + // For index.json, try to read it + // 对于 index.json,尝试读取它 + if (path.endsWith('/index.json') || path === 'index.json') { + console.log('[TauriModuleFileSystem] Checking index.json via Tauri command...'); + await invoke('read_engine_modules_index'); + console.log('[TauriModuleFileSystem] index.json exists'); + return true; + } + + // For module.json, check if module exists in index + // 对于 module.json,检查模块是否存在于索引中 + const match = path.match(/\/([^/]+)\/module\.json$/); + if (match) { + const moduleId = match[1]; + // Use cached index if available + // 如果有缓存的索引则使用 + if (this._indexCache) { + return this._indexCache.modules.some(m => m.id === moduleId); + } + // Otherwise try to read the manifest + // 否则尝试读取清单 + try { + await invoke('read_module_manifest', { moduleId }); + return true; + } catch { + return false; + } + } + + return false; + } catch (err) { + console.error('[TauriModuleFileSystem] pathExists error:', err); + return false; + } + } + + /** + * List files - not needed for module loading. + * 列出文件 - 模块加载不需要。 + */ + async listFiles(_dir: string, _extensions: string[], _recursive?: boolean): Promise { + return []; + } + + /** + * Read file as text. + * 读取文件为文本。 + */ + async readText(path: string): Promise { + const json = await this.readJson(path); + return JSON.stringify(json); + } + + /** + * Get the base path to engine modules. + * 获取引擎模块的基础路径。 + */ + async getBasePath(): Promise { + if (!this._basePath) { + this._basePath = await invoke('get_engine_modules_base_path'); + } + return this._basePath; + } +} + diff --git a/packages/editor-app/src/services/ViewportService.ts b/packages/editor-app/src/services/ViewportService.ts new file mode 100644 index 00000000..ccb3c98d --- /dev/null +++ b/packages/editor-app/src/services/ViewportService.ts @@ -0,0 +1,120 @@ +/** + * Viewport Service Implementation + * 视口服务实现 + * + * Implements IViewportService using EngineService. + * 使用 EngineService 实现 IViewportService。 + */ + +import type { IViewportService, ViewportCameraConfig } from '@esengine/editor-core'; +import { EngineService } from './EngineService'; + +/** + * ViewportService - Wraps EngineService for IViewportService interface + * ViewportService - 为 IViewportService 接口包装 EngineService + */ +export class ViewportService implements IViewportService { + private static _instance: ViewportService | null = null; + private _engineService: EngineService; + + private constructor() { + this._engineService = EngineService.getInstance(); + } + + /** + * Get singleton instance + * 获取单例实例 + */ + static getInstance(): ViewportService { + if (!ViewportService._instance) { + ViewportService._instance = new ViewportService(); + } + return ViewportService._instance; + } + + /** + * Check if the service is initialized + * 检查服务是否已初始化 + */ + isInitialized(): boolean { + return this._engineService.isInitialized(); + } + + /** + * Register a viewport with a canvas element + * 注册一个视口和画布元素 + */ + registerViewport(viewportId: string, canvasId: string): void { + this._engineService.registerViewport(viewportId, canvasId); + } + + /** + * Unregister a viewport + * 注销一个视口 + */ + unregisterViewport(viewportId: string): void { + this._engineService.unregisterViewport(viewportId); + } + + /** + * Set camera for a specific viewport + * 设置特定视口的相机 + */ + setViewportCamera(viewportId: string, config: ViewportCameraConfig): void { + this._engineService.setViewportCamera(viewportId, { + x: config.x, + y: config.y, + zoom: config.zoom, + rotation: config.rotation ?? 0 + }); + } + + /** + * Get camera for a specific viewport + * 获取特定视口的相机 + */ + getViewportCamera(viewportId: string): ViewportCameraConfig | null { + return this._engineService.getViewportCamera(viewportId); + } + + /** + * Set viewport configuration (grid, gizmos visibility) + * 设置视口配置(网格、辅助线可见性) + */ + setViewportConfig(viewportId: string, showGrid: boolean, showGizmos: boolean): void { + this._engineService.setViewportConfig(viewportId, showGrid, showGizmos); + } + + /** + * Resize a specific viewport + * 调整特定视口的大小 + */ + resizeViewport(viewportId: string, width: number, height: number): void { + this._engineService.resizeViewport(viewportId, width, height); + } + + /** + * Render to a specific viewport + * 渲染到特定视口 + */ + renderToViewport(viewportId: string): void { + this._engineService.renderToViewport(viewportId); + } + + /** + * Load a texture and return its ID + * 加载纹理并返回其 ID + */ + async loadTexture(path: string): Promise { + return await this._engineService.loadTextureAsset(path); + } + + /** + * Dispose resources + * 释放资源 + */ + dispose(): void { + // ViewportService is a lightweight wrapper, no resources to dispose + // The underlying EngineService manages its own lifecycle + } +} diff --git a/packages/editor-app/src/styles/BuildSettingsPanel.css b/packages/editor-app/src/styles/BuildSettingsPanel.css new file mode 100644 index 00000000..0693e851 --- /dev/null +++ b/packages/editor-app/src/styles/BuildSettingsPanel.css @@ -0,0 +1,909 @@ +/** + * Build Settings Panel Styles. + * 构建设置面板样式。 + * + * Designed to match the editor's existing panel style. + * 设计与编辑器现有面板风格一致。 + */ + +/* ==================== Main Container | 主容器 ==================== */ + +.build-settings-panel { + display: flex; + flex-direction: column; + height: 100%; + background-color: #2a2a2a; + color: #e0e0e0; + font-size: 11px; +} + +/* ==================== Header Tabs | 头部标签 ==================== */ + +.build-settings-header { + display: flex; + align-items: center; + justify-content: space-between; + height: 28px; + padding: 0 8px; + background: #2d2d2d; + border-bottom: 1px solid #1a1a1a; + flex-shrink: 0; +} + +.build-settings-tabs { + display: flex; + align-items: center; + gap: 2px; +} + +.build-settings-tab { + display: flex; + align-items: center; + gap: 4px; + padding: 4px 10px; + background: transparent; + border: none; + border-radius: 2px; + color: #888; + font-size: 11px; + cursor: pointer; + transition: all 0.1s ease; +} + +.build-settings-tab:hover { + background: #3a3a3a; + color: #ccc; +} + +.build-settings-tab.active { + background: #3a3a3a; + color: #e0e0e0; +} + +.build-settings-header-actions { + display: flex; + align-items: center; + gap: 4px; +} + +.build-settings-header-btn { + padding: 3px 8px; + background: transparent; + border: 1px solid #3a3a3a; + border-radius: 2px; + color: #888; + font-size: 10px; + cursor: pointer; + transition: all 0.1s ease; +} + +.build-settings-header-btn:hover { + background: #3a3a3a; + border-color: #4a4a4a; + color: #ccc; +} + +/* ==================== Add Profile Bar | 添加配置栏 ==================== */ + +.build-settings-add-bar { + display: flex; + align-items: center; + padding: 4px 8px; + background: #262626; + border-bottom: 1px solid #1a1a1a; +} + +.build-settings-add-btn { + display: flex; + align-items: center; + gap: 4px; + padding: 4px 8px; + background: transparent; + border: 1px dashed #3a3a3a; + border-radius: 2px; + color: #888; + font-size: 11px; + cursor: pointer; + transition: all 0.1s ease; +} + +.build-settings-add-btn:hover { + background: rgba(59, 130, 246, 0.1); + border-color: #3b82f6; + color: #3b82f6; +} + +/* ==================== Main Content | 主要内容 ==================== */ + +.build-settings-content { + flex: 1; + display: flex; + overflow: hidden; +} + +/* ==================== Sidebar | 侧边栏 ==================== */ + +.build-settings-sidebar { + width: 200px; + min-width: 200px; + display: flex; + flex-direction: column; + background: #262626; + border-right: 1px solid #1a1a1a; + overflow-y: auto; +} + +.build-settings-section { + display: flex; + flex-direction: column; +} + +.build-settings-section-header { + padding: 6px 8px; + font-size: 10px; + font-weight: 600; + color: #888; + text-transform: uppercase; + letter-spacing: 0.05em; + background: #2d2d2d; + border-bottom: 1px solid #1a1a1a; +} + +/* ==================== Platform List | 平台列表 ==================== */ + +.build-settings-platform-list { + display: flex; + flex-direction: column; +} + +.build-settings-platform-item { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 12px; + cursor: pointer; + transition: background 0.1s ease; + border-bottom: 1px solid transparent; +} + +.build-settings-platform-item:hover { + background: rgba(255, 255, 255, 0.03); +} + +.build-settings-platform-item.selected { + background: #3d5a80; +} + +.build-settings-platform-item.selected:hover { + background: #4a6a90; +} + +.build-settings-platform-icon { + display: flex; + align-items: center; + color: #888; +} + +.build-settings-platform-item.selected .build-settings-platform-icon { + color: #e0e0e0; +} + +.build-settings-platform-label { + flex: 1; + font-size: 11px; + color: #ccc; +} + +.build-settings-platform-item.selected .build-settings-platform-label { + color: #fff; +} + +.build-settings-active-badge { + padding: 1px 6px; + background: rgba(74, 222, 128, 0.15); + border-radius: 2px; + font-size: 9px; + color: #4ade80; +} + +/* ==================== Profile List | 配置列表 ==================== */ + +.build-settings-profile-list { + display: flex; + flex-direction: column; +} + +.build-settings-profile-item { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 12px; + cursor: pointer; + transition: background 0.1s ease; +} + +.build-settings-profile-item:hover { + background: rgba(255, 255, 255, 0.03); +} + +.build-settings-profile-item.selected { + background: #3d5a80; +} + +.build-settings-profile-icon { + display: flex; + align-items: center; + color: #888; +} + +.build-settings-profile-item.selected .build-settings-profile-icon { + color: #e0e0e0; +} + +.build-settings-profile-name { + flex: 1; + font-size: 11px; + color: #ccc; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.build-settings-profile-item.selected .build-settings-profile-name { + color: #fff; +} + +/* ==================== Details Panel | 详情面板 ==================== */ + +.build-settings-details { + flex: 1; + display: flex; + flex-direction: column; + overflow-y: auto; + background: #2a2a2a; +} + +.build-settings-details-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 12px; + background: #2d2d2d; + border-bottom: 1px solid #1a1a1a; + flex-shrink: 0; +} + +.build-settings-details-title { + display: flex; + align-items: center; + gap: 10px; +} + +.build-settings-details-icon { + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + background: #3a3a3a; + border-radius: 3px; + color: #ccc; +} + +.build-settings-details-info h3 { + margin: 0; + font-size: 12px; + font-weight: 500; + color: #e0e0e0; +} + +.build-settings-details-info span { + font-size: 10px; + color: #888; +} + +.build-settings-details-actions { + display: flex; + align-items: center; + gap: 6px; +} + +/* ==================== Buttons | 按钮 ==================== */ + +.build-settings-btn { + display: flex; + align-items: center; + gap: 4px; + padding: 5px 12px; + border-radius: 2px; + font-size: 11px; + cursor: pointer; + transition: all 0.1s ease; +} + +.build-settings-btn.primary { + background: #3b82f6; + border: 1px solid #3b82f6; + color: #fff; +} + +.build-settings-btn.primary:hover { + background: #2563eb; + border-color: #2563eb; +} + +.build-settings-btn.secondary { + background: #3a3a3a; + border: 1px solid #4a4a4a; + color: #ccc; +} + +.build-settings-btn.secondary:hover { + background: #444; + border-color: #555; + color: #fff; +} + +.build-settings-btn.text { + background: transparent; + border: none; + color: #3b82f6; + padding: 4px 8px; +} + +.build-settings-btn.text:hover { + background: rgba(59, 130, 246, 0.1); +} + +.build-settings-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* ==================== Card | 卡片 ==================== */ + +.build-settings-card { + margin: 0; + background: #262626; + border-bottom: 1px solid #1a1a1a; +} + +.build-settings-card-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 6px 12px; + font-size: 10px; + font-weight: 600; + color: #888; + text-transform: uppercase; + letter-spacing: 0.05em; + background: #2d2d2d; + border-bottom: 1px solid #1a1a1a; +} + +.build-settings-more-btn { + display: flex; + align-items: center; + justify-content: center; + width: 18px; + height: 18px; + padding: 0; + background: transparent; + border: none; + border-radius: 2px; + color: #666; + cursor: pointer; + transition: all 0.1s ease; +} + +.build-settings-more-btn:hover { + background: #3a3a3a; + color: #ccc; +} + +/* ==================== Field Group | 字段组 ==================== */ + +.build-settings-field-group { + border-bottom: 1px solid #1a1a1a; +} + +.build-settings-field-group:last-child { + border-bottom: none; +} + +.build-settings-field-header { + display: flex; + align-items: center; + gap: 4px; + padding: 6px 12px; + cursor: pointer; + color: #ccc; + font-size: 11px; + transition: background 0.1s ease; +} + +.build-settings-field-header:hover { + background: rgba(255, 255, 255, 0.03); +} + +.build-settings-field-header svg { + color: #888; +} + +.build-settings-field-content { + padding: 8px 12px; + background: #262626; +} + +/* ==================== Scene List | 场景列表 ==================== */ + +.build-settings-scene-list { + display: flex; + flex-direction: column; + gap: 2px; + min-height: 40px; + background: #1a1a1a; + border: 1px solid #3a3a3a; + border-radius: 2px; + padding: 4px; +} + +.build-settings-scene-item { + display: flex; + align-items: center; + gap: 8px; + padding: 4px 8px; + font-size: 11px; + color: #ccc; + border-radius: 2px; +} + +.build-settings-scene-item:hover { + background: rgba(255, 255, 255, 0.03); +} + +.build-settings-scene-item input[type="checkbox"] { + width: 14px; + height: 14px; + accent-color: #3b82f6; +} + +.build-settings-empty-list { + min-height: 30px; +} + +.build-settings-empty-text { + padding: 8px; + text-align: center; + color: #666; + font-size: 11px; + font-style: italic; +} + +.build-settings-field-actions { + display: flex; + justify-content: flex-end; + margin-top: 6px; +} + +/* ==================== Defines List | 定义列表 ==================== */ + +.build-settings-defines-list { + display: flex; + flex-direction: column; + gap: 2px; + min-height: 30px; + background: #1a1a1a; + border: 1px solid #3a3a3a; + border-radius: 2px; + padding: 4px; +} + +.build-settings-define-item { + display: flex; + align-items: center; + justify-content: space-between; + padding: 4px 8px; + font-size: 11px; + color: #ccc; + border-radius: 2px; +} + +.build-settings-define-item:hover { + background: rgba(255, 255, 255, 0.03); +} + +.build-settings-define-item button { + display: flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; + padding: 0; + background: transparent; + border: none; + color: #666; + cursor: pointer; + border-radius: 2px; +} + +.build-settings-define-item button:hover { + background: #ef4444; + color: #fff; +} + +.build-settings-list-actions { + display: flex; + justify-content: flex-end; + gap: 2px; + margin-top: 4px; +} + +.build-settings-list-actions button { + display: flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + padding: 0; + background: #2a2a2a; + border: 1px solid #3a3a3a; + border-radius: 2px; + color: #888; + cursor: pointer; + transition: all 0.1s ease; +} + +.build-settings-list-actions button:hover:not(:disabled) { + background: #3a3a3a; + border-color: #4a4a4a; + color: #ccc; +} + +.build-settings-list-actions button:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +/* ==================== Form | 表单 ==================== */ + +.build-settings-form { + display: flex; + flex-direction: column; + gap: 0; +} + +.build-settings-form-row { + display: flex; + align-items: center; + min-height: 26px; + padding: 3px 0; + gap: 8px; +} + +.build-settings-form-row:hover { + background: rgba(255, 255, 255, 0.02); +} + +.build-settings-form-row label { + flex: 0 0 35%; + min-width: 80px; + max-width: 140px; + font-size: 11px; + color: #999; +} + +.build-settings-form-row input[type="text"], +.build-settings-form-row select { + flex: 1; + height: 22px; + padding: 0 8px; + background: #1a1a1a; + border: 1px solid #3a3a3a; + border-radius: 2px; + color: #ddd; + font-size: 11px; + font-family: 'Consolas', 'Monaco', monospace; +} + +.build-settings-form-row input[type="text"]:hover, +.build-settings-form-row select:hover { + border-color: #4a4a4a; + background: #1e1e1e; +} + +.build-settings-form-row input[type="text"]:focus, +.build-settings-form-row select:focus { + border-color: #3b82f6; + outline: none; +} + +.build-settings-form-row input[type="checkbox"] { + width: 14px; + height: 14px; + accent-color: #3b82f6; +} + +/* Icon Picker | 图标选择器 */ + +.build-settings-icon-picker { + display: flex; + align-items: center; + gap: 8px; + flex: 1; + height: 22px; + padding: 0 8px; + background: #1a1a1a; + border: 1px solid #3a3a3a; + border-radius: 2px; + color: #666; + font-size: 11px; + font-style: italic; +} + +.build-settings-icon-hint { + font-size: 10px; + color: #555; +} + +/* ==================== No Selection | 无选择 ==================== */ + +.build-settings-no-selection { + display: flex; + align-items: center; + justify-content: center; + height: 100%; + color: #666; + font-size: 11px; +} + +/* ==================== Scrollbar | 滚动条 ==================== */ + +.build-settings-sidebar::-webkit-scrollbar, +.build-settings-details::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +.build-settings-sidebar::-webkit-scrollbar-track, +.build-settings-details::-webkit-scrollbar-track { + background: #2a2a2a; +} + +.build-settings-sidebar::-webkit-scrollbar-thumb, +.build-settings-details::-webkit-scrollbar-thumb { + background: #4a4a4a; + border-radius: 4px; + border: 2px solid #2a2a2a; +} + +.build-settings-sidebar::-webkit-scrollbar-thumb:hover, +.build-settings-details::-webkit-scrollbar-thumb:hover { + background: #5a5a5a; +} + +/* ==================== Build Progress Dialog | 构建进度对话框 ==================== */ + +.build-progress-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.6); + display: flex; + align-items: center; + justify-content: center; + z-index: 2000; +} + +.build-progress-dialog { + display: flex; + flex-direction: column; + width: 360px; + background: #2a2a2a; + border: 1px solid #3a3a3a; + border-radius: 4px; + overflow: hidden; +} + +.build-progress-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 12px; + background: #2d2d2d; + border-bottom: 1px solid #1a1a1a; +} + +.build-progress-header h3 { + margin: 0; + font-size: 12px; + font-weight: 500; + color: #e0e0e0; +} + +.build-progress-close { + display: flex; + align-items: center; + justify-content: center; + width: 20px; + height: 20px; + padding: 0; + background: transparent; + border: none; + border-radius: 2px; + color: #888; + cursor: pointer; +} + +.build-progress-close:hover { + background: #3a3a3a; + color: #ccc; +} + +.build-progress-content { + display: flex; + flex-direction: column; + align-items: center; + padding: 24px 20px; + gap: 12px; +} + +.build-progress-status-icon { + display: flex; + align-items: center; + justify-content: center; +} + +.build-progress-spinner { + color: #3b82f6; + animation: spin 1s linear infinite; +} + +@keyframes spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} + +.build-progress-success { + color: #4ade80; +} + +.build-progress-error { + color: #ef4444; +} + +.build-progress-message { + font-size: 11px; + color: #ccc; + text-align: center; +} + +.build-progress-bar-container { + position: relative; + width: 100%; + height: 4px; + background: #3a3a3a; + border-radius: 2px; + overflow: hidden; +} + +.build-progress-bar { + height: 100%; + background: #3b82f6; + border-radius: 2px; + transition: width 0.2s ease; +} + +.build-progress-percent { + position: absolute; + top: 8px; + left: 50%; + transform: translateX(-50%); + font-size: 10px; + color: #888; +} + +/* Build Result Details | 构建结果详情 */ + +.build-result-details { + width: 100%; + display: flex; + flex-direction: column; + gap: 6px; + margin-top: 4px; +} + +.build-result-row { + display: flex; + align-items: center; + gap: 8px; + font-size: 11px; +} + +.build-result-label { + color: #888; +} + +.build-result-value { + color: #ccc; + word-break: break-all; + font-family: 'Consolas', 'Monaco', monospace; +} + +.build-result-error { + display: flex; + align-items: flex-start; + gap: 6px; + padding: 8px 10px; + background: rgba(239, 68, 68, 0.1); + border: 1px solid rgba(239, 68, 68, 0.3); + border-radius: 2px; + color: #ef4444; + font-size: 11px; +} + +.build-result-error svg { + flex-shrink: 0; + margin-top: 1px; +} + +.build-result-warnings { + display: flex; + flex-direction: column; + gap: 6px; + padding: 8px 10px; + background: rgba(245, 158, 11, 0.1); + border: 1px solid rgba(245, 158, 11, 0.3); + border-radius: 2px; +} + +.build-result-warnings-header { + display: flex; + align-items: center; + gap: 6px; + color: #f59e0b; + font-size: 11px; + font-weight: 500; +} + +.build-result-warnings-list { + margin: 0; + padding-left: 20px; + color: #ccc; + font-size: 10px; +} + +.build-result-warnings-list li { + margin-bottom: 2px; +} + +.build-progress-actions { + display: flex; + justify-content: center; + padding: 12px; + border-top: 1px solid #1a1a1a; + background: #262626; +} + +.build-progress-actions .build-settings-btn { + min-width: 80px; + justify-content: center; +} + +/* ==================== Toggle Group | 开关组 ==================== */ + +.build-settings-toggle-group { + display: flex; + align-items: center; + gap: 8px; + flex: 1; +} + +.build-settings-hint { + font-size: 10px; + color: #666; + font-style: italic; +} diff --git a/packages/editor-app/src/styles/BuildSettingsWindow.css b/packages/editor-app/src/styles/BuildSettingsWindow.css new file mode 100644 index 00000000..6c65e724 --- /dev/null +++ b/packages/editor-app/src/styles/BuildSettingsWindow.css @@ -0,0 +1,73 @@ +/** + * Build Settings Window Styles. + * 构建设置窗口样式。 + */ + +.build-settings-window-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.6); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; +} + +.build-settings-window { + display: flex; + flex-direction: column; + width: 90%; + max-width: 900px; + height: 80%; + max-height: 600px; + background: #2a2a2a; + border: 1px solid #3a3a3a; + border-radius: 4px; + overflow: hidden; +} + +.build-settings-window-header { + display: flex; + align-items: center; + justify-content: space-between; + height: 32px; + padding: 0 12px; + background: #2d2d2d; + border-bottom: 1px solid #1a1a1a; + flex-shrink: 0; +} + +.build-settings-window-header h2 { + margin: 0; + font-size: 12px; + font-weight: 500; + color: #e0e0e0; +} + +.build-settings-window-close { + display: flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + padding: 0; + background: transparent; + border: none; + border-radius: 2px; + color: #888; + cursor: pointer; + transition: all 0.1s ease; +} + +.build-settings-window-close:hover { + background: #3a3a3a; + color: #e0e0e0; +} + +.build-settings-window-content { + flex: 1; + overflow: hidden; +} diff --git a/packages/editor-app/src/styles/EditorViewport.css b/packages/editor-app/src/styles/EditorViewport.css new file mode 100644 index 00000000..da5afa91 --- /dev/null +++ b/packages/editor-app/src/styles/EditorViewport.css @@ -0,0 +1,104 @@ +/** + * EditorViewport Styles + * 编辑器视口样式 + */ + +.editor-viewport { + position: relative; + width: 100%; + height: 100%; + overflow: hidden; + background-color: var(--bg-viewport, #1a1a1a); +} + +.editor-viewport-canvas { + display: block; + width: 100%; + height: 100%; + cursor: grab; +} + +.editor-viewport-canvas:active { + cursor: grabbing; +} + +/* Overlay container */ +.editor-viewport-overlay { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + pointer-events: none; +} + +.editor-viewport-overlay > * { + pointer-events: auto; +} + +/* Toolbar overlay */ +.editor-viewport-toolbar { + position: absolute; + top: 8px; + left: 8px; + display: flex; + gap: 4px; + padding: 4px; + background: var(--bg-toolbar, rgba(30, 30, 30, 0.9)); + border-radius: 4px; + border: 1px solid var(--border-color, #333); +} + +.editor-viewport-toolbar-right { + left: auto; + right: 8px; +} + +/* Stats overlay */ +.editor-viewport-stats { + position: absolute; + bottom: 8px; + left: 8px; + padding: 8px; + background: var(--bg-toolbar, rgba(30, 30, 30, 0.9)); + border-radius: 4px; + border: 1px solid var(--border-color, #333); + font-size: 11px; + color: var(--text-secondary, #888); +} + +.editor-viewport-stats-row { + display: flex; + gap: 8px; +} + +.editor-viewport-stats-label { + color: var(--text-muted, #666); +} + +.editor-viewport-stats-value { + color: var(--text-primary, #ccc); +} + +/* Zoom indicator */ +.editor-viewport-zoom { + position: absolute; + bottom: 8px; + right: 8px; + padding: 4px 8px; + background: var(--bg-toolbar, rgba(30, 30, 30, 0.9)); + border-radius: 4px; + border: 1px solid var(--border-color, #333); + font-size: 11px; + color: var(--text-secondary, #888); +} + +/* Crosshair cursor mode */ +.editor-viewport.crosshair .editor-viewport-canvas { + cursor: crosshair; +} + +/* Pointer cursor mode */ +.editor-viewport.pointer .editor-viewport-canvas { + cursor: default; +} diff --git a/packages/editor-app/src/styles/SettingsWindow.css b/packages/editor-app/src/styles/SettingsWindow.css index c6f62a73..cf1dd889 100644 --- a/packages/editor-app/src/styles/SettingsWindow.css +++ b/packages/editor-app/src/styles/SettingsWindow.css @@ -442,7 +442,8 @@ /* ==================== Special Types ==================== */ .settings-plugin-list, -.settings-custom-renderer { +.settings-custom-renderer, +.settings-module-list { padding: 8px 16px; } diff --git a/packages/editor-app/vite.config.ts b/packages/editor-app/vite.config.ts index 9f339289..dbe740e4 100644 --- a/packages/editor-app/vite.config.ts +++ b/packages/editor-app/vite.config.ts @@ -58,6 +58,342 @@ function copyPluginModulesPlugin(): Plugin { }; } +/** + * Plugin to copy engine modules after each build. + * 每次构建后复制引擎模块的插件。 + */ +function copyEngineModulesPlugin(): Plugin { + const packagesDir = path.resolve(__dirname, '..'); + + function getEngineModules() { + const modules: Array<{ + id: string; + name: string; + displayName: string; + packageDir: string; + moduleJsonPath: string; + distPath: string; + editorPackage?: string; + isCore: boolean; + category: string; + }> = []; + + let packages: string[]; + try { + packages = fs.readdirSync(packagesDir); + } catch { + return modules; + } + + for (const pkg of packages) { + const pkgDir = path.join(packagesDir, pkg); + const moduleJsonPath = path.join(pkgDir, 'module.json'); + + try { + if (!fs.statSync(pkgDir).isDirectory()) continue; + } catch { + continue; + } + + if (!fs.existsSync(moduleJsonPath)) continue; + + try { + const moduleJson = JSON.parse(fs.readFileSync(moduleJsonPath, 'utf-8')); + if (moduleJson.isEngineModule !== false) { + // Use outputPath from module.json, default to "dist/index.js" + const outputPath = moduleJson.outputPath || 'dist/index.js'; + const distPath = path.join(pkgDir, outputPath); + + modules.push({ + id: moduleJson.id || pkg, + name: moduleJson.name || `@esengine/${pkg}`, + displayName: moduleJson.displayName || pkg, + packageDir: pkgDir, + moduleJsonPath, + distPath, + editorPackage: moduleJson.editorPackage, + isCore: moduleJson.isCore || false, + category: moduleJson.category || 'Other' + }); + } + } catch { + // Ignore parse errors + } + } + + return modules; + } + + return { + name: 'copy-engine-modules', + writeBundle(options) { + const outDir = options.dir || 'dist'; + const engineDir = path.join(outDir, 'engine'); + + // Clean and recreate engine directory + if (fs.existsSync(engineDir)) { + fs.rmSync(engineDir, { recursive: true }); + } + fs.mkdirSync(engineDir, { recursive: true }); + + const modules = getEngineModules(); + const moduleInfos: Array<{ + id: string; + name: string; + displayName: string; + hasRuntime: boolean; + editorPackage?: string; + isCore: boolean; + category: string; + jsSize?: number; + requiresWasm?: boolean; + wasmSize?: number; + wasmFiles?: string[]; + }> = []; + + const editorPackages = new Set(); + + /** + * Calculate total WASM file size in a directory. + * 计算目录中 WASM 文件的总大小。 + */ + function getWasmSize(pkgDir: string): number { + let totalSize = 0; + const checkDirs = [ + pkgDir, + path.join(pkgDir, 'pkg'), + path.join(pkgDir, 'dist') + ]; + + for (const dir of checkDirs) { + if (!fs.existsSync(dir)) continue; + try { + const files = fs.readdirSync(dir); + for (const file of files) { + if (file.endsWith('.wasm')) { + const filePath = path.join(dir, file); + const stat = fs.statSync(filePath); + totalSize += stat.size; + } + } + } catch { + // Ignore errors + } + } + return totalSize; + } + + console.log(`[copy-engine-modules] Copying ${modules.length} modules to dist/engine/`); + + for (const module of modules) { + const moduleOutputDir = path.join(engineDir, module.id); + fs.mkdirSync(moduleOutputDir, { recursive: true }); + + // Read full module.json for additional fields + // 读取完整 module.json 获取额外字段 + let moduleJson: Record = {}; + try { + moduleJson = JSON.parse(fs.readFileSync(module.moduleJsonPath, 'utf-8')); + } catch { + // Ignore parse errors + } + + // Copy module.json + fs.copyFileSync(module.moduleJsonPath, path.join(moduleOutputDir, 'module.json')); + + // Copy dist/index.js if exists + let hasRuntime = false; + let jsSize = 0; + if (fs.existsSync(module.distPath)) { + fs.copyFileSync(module.distPath, path.join(moduleOutputDir, 'index.js')); + // Get JS file size + jsSize = fs.statSync(module.distPath).size; + // Copy source map if exists + const sourceMapPath = module.distPath + '.map'; + if (fs.existsSync(sourceMapPath)) { + fs.copyFileSync(sourceMapPath, path.join(moduleOutputDir, 'index.js.map')); + } + hasRuntime = true; + + // Copy additional included files (e.g., chunks) + // 复制额外包含的文件(如 chunk) + const includes = moduleJson.includes as string[] | undefined; + if (includes && includes.length > 0) { + const distDir = path.dirname(module.distPath); + for (const pattern of includes) { + // Convert glob pattern to regex + const regexPattern = pattern + .replace(/\./g, '\\.') + .replace(/\*/g, '.*') + .replace(/\?/g, '.'); + const regex = new RegExp(`^${regexPattern}$`); + + // Find matching files in dist directory + if (fs.existsSync(distDir)) { + const files = fs.readdirSync(distDir); + for (const file of files) { + if (regex.test(file)) { + const srcFile = path.join(distDir, file); + const destFile = path.join(moduleOutputDir, file); + fs.copyFileSync(srcFile, destFile); + jsSize += fs.statSync(srcFile).size; + // Copy source map for included file if exists + const mapFile = srcFile + '.map'; + if (fs.existsSync(mapFile)) { + fs.copyFileSync(mapFile, destFile + '.map'); + } + console.log(`[copy-engine-modules] Copied include to ${module.id}/: ${file}`); + } + } + } + } + } + } + + // Calculate WASM size and copy WASM files if module requires WASM + // 如果模块需要 WASM,计算 WASM 大小并复制 WASM 文件 + const requiresWasm = moduleJson.requiresWasm === true; + let wasmSize = 0; + const copiedWasmFiles: string[] = []; + if (requiresWasm) { + wasmSize = getWasmSize(module.packageDir); + if (wasmSize > 0) { + console.log(`[copy-engine-modules] ${module.id}: WASM size = ${(wasmSize / 1024).toFixed(1)} KB`); + } + + // Copy WASM files from wasmPaths defined in module.json + // wasmPaths 现在是相对于源包目录的路径,如 "rapier_wasm2d_bg.wasm" + // 需要找到实际的 WASM 文件并复制到输出的模块目录 + const wasmPaths = moduleJson.wasmPaths as string[] | undefined; + if (wasmPaths && wasmPaths.length > 0) { + for (const wasmRelPath of wasmPaths) { + const wasmFileName = path.basename(wasmRelPath); + + // 查找源 WASM 文件的可能位置 + // wasmPaths 里配置的是相对路径,实际文件在源包里 + // 对于 @esengine/rapier2d,WASM 在 packages/rapier2d/pkg/ 下 + const possibleSrcPaths = [ + // 直接在包目录下(如果 wasmRelPath 就是文件名) + path.join(module.packageDir, wasmRelPath), + // 在包的 pkg 目录下(wasm-pack 输出) + path.join(module.packageDir, 'pkg', wasmFileName), + // 在包的 dist 目录下 + path.join(module.packageDir, 'dist', wasmFileName), + ]; + + // 对于依赖其他包 WASM 的情况,检查依赖包 + // 例如 physics-rapier2d 依赖 rapier2d 的 WASM + const depMatch = moduleJson.name?.toString().match(/@esengine\/(.+)/); + if (depMatch) { + // 检查同名的依赖包(去掉 physics- 前缀) + const baseName = depMatch[1].replace('physics-', ''); + possibleSrcPaths.push( + path.join(packagesDir, baseName, 'pkg', wasmFileName), + path.join(packagesDir, baseName, wasmFileName) + ); + } + + let copied = false; + for (const srcPath of possibleSrcPaths) { + if (fs.existsSync(srcPath)) { + const destPath = path.join(moduleOutputDir, wasmFileName); + fs.copyFileSync(srcPath, destPath); + copiedWasmFiles.push(wasmFileName); + console.log(`[copy-engine-modules] Copied WASM to ${module.id}/: ${wasmFileName}`); + copied = true; + break; + } + } + + if (!copied) { + console.warn(`[copy-engine-modules] WASM file not found: ${wasmRelPath} (tried ${possibleSrcPaths.length} paths)`); + } + } + } + + // Copy pkg directory if exists (for WASM JS bindings like rapier2d) + // 如果存在 pkg 目录则复制(用于 WASM JS 绑定如 rapier2d) + // The JS and WASM files must be in the same directory for import.meta.url to work + // JS 和 WASM 文件必须在同一目录才能让 import.meta.url 正常工作 + const pkgDir = path.join(module.packageDir, 'pkg'); + if (fs.existsSync(pkgDir)) { + const pkgOutputDir = path.join(moduleOutputDir, 'pkg'); + fs.mkdirSync(pkgOutputDir, { recursive: true }); + const pkgFiles = fs.readdirSync(pkgDir); + for (const file of pkgFiles) { + // Copy both JS and WASM files to pkg directory + // 将 JS 和 WASM 文件都复制到 pkg 目录 + if (file.endsWith('.js') || file.endsWith('.wasm')) { + const srcFile = path.join(pkgDir, file); + const destFile = path.join(pkgOutputDir, file); + fs.copyFileSync(srcFile, destFile); + console.log(`[copy-engine-modules] Copied pkg to ${module.id}/pkg/: ${file}`); + } + } + } + } + + moduleInfos.push({ + id: module.id, + name: module.name, + displayName: module.displayName, + hasRuntime, + editorPackage: module.editorPackage, + isCore: module.isCore, + category: module.category, + // Only include jsSize if there's actual runtime code + // 只有实际有运行时代码时才包含 jsSize + jsSize: jsSize > 0 ? jsSize : undefined, + requiresWasm: requiresWasm || undefined, + wasmSize: wasmSize > 0 ? wasmSize : undefined, + // WASM files that were copied to dist/wasm/ + // 复制到 dist/wasm/ 的 WASM 文件 + wasmFiles: copiedWasmFiles.length > 0 ? copiedWasmFiles : undefined + }); + + if (module.editorPackage) { + editorPackages.add(module.editorPackage); + } + } + + // Copy editor packages + for (const editorPkg of editorPackages) { + const match = editorPkg.match(/@esengine\/(.+)/); + if (!match) continue; + + const pkgName = match[1]; + const pkgDir = path.join(packagesDir, pkgName); + const distPath = path.join(pkgDir, 'dist', 'index.js'); + + if (!fs.existsSync(distPath)) continue; + + const editorOutputDir = path.join(engineDir, pkgName); + fs.mkdirSync(editorOutputDir, { recursive: true }); + fs.copyFileSync(distPath, path.join(editorOutputDir, 'index.js')); + + const sourceMapPath = distPath + '.map'; + if (fs.existsSync(sourceMapPath)) { + fs.copyFileSync(sourceMapPath, path.join(editorOutputDir, 'index.js.map')); + } + } + + // Create index.json + const indexData = { + version: '1.0.0', + generatedAt: new Date().toISOString(), + modules: moduleInfos + }; + + fs.writeFileSync( + path.join(engineDir, 'index.json'), + JSON.stringify(indexData, null, 2) + ); + + console.log(`[copy-engine-modules] Done! Created dist/engine/index.json`); + } + }; +} + const host = process.env.TAURI_DEV_HOST; const wasmPackages: string[] = []; @@ -161,6 +497,7 @@ export default defineConfig({ tsDecorators: true, }), copyPluginModulesPlugin(), + copyEngineModulesPlugin(), ], clearScreen: false, server: {