feat(editor-app): 重构浏览器预览使用import maps
This commit is contained in:
@@ -9,20 +9,26 @@
|
|||||||
"build": "npm run build:sdk && tsc && vite build",
|
"build": "npm run build:sdk && tsc && vite build",
|
||||||
"build:watch": "vite build --watch",
|
"build:watch": "vite build --watch",
|
||||||
"tauri": "tauri",
|
"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",
|
"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"
|
"version": "node scripts/sync-version.js && git add src-tauri/tauri.conf.json"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@esengine/asset-system": "workspace:*",
|
"@esengine/asset-system": "workspace:*",
|
||||||
|
"@esengine/asset-system-editor": "workspace:*",
|
||||||
"@esengine/behavior-tree": "workspace:*",
|
"@esengine/behavior-tree": "workspace:*",
|
||||||
|
"@esengine/material-system": "workspace:*",
|
||||||
|
"@esengine/material-editor": "workspace:*",
|
||||||
"@esengine/behavior-tree-editor": "workspace:*",
|
"@esengine/behavior-tree-editor": "workspace:*",
|
||||||
"@esengine/blueprint": "workspace:*",
|
"@esengine/blueprint": "workspace:*",
|
||||||
"@esengine/blueprint-editor": "workspace:*",
|
"@esengine/blueprint-editor": "workspace:*",
|
||||||
"@esengine/editor-runtime": "workspace:*",
|
"@esengine/editor-runtime": "workspace:*",
|
||||||
"@esengine/engine-core": "workspace:*",
|
"@esengine/engine-core": "workspace:*",
|
||||||
"@esengine/sprite": "workspace:*",
|
"@esengine/sprite": "workspace:*",
|
||||||
|
"@esengine/sprite-editor": "workspace:*",
|
||||||
|
"@esengine/shader-editor": "workspace:*",
|
||||||
"@esengine/camera": "workspace:*",
|
"@esengine/camera": "workspace:*",
|
||||||
"@esengine/audio": "workspace:*",
|
"@esengine/audio": "workspace:*",
|
||||||
"@esengine/physics-rapier2d": "workspace:*",
|
"@esengine/physics-rapier2d": "workspace:*",
|
||||||
|
|||||||
101
packages/editor-app/src-tauri/Cargo.lock
generated
101
packages/editor-app/src-tauri/Cargo.lock
generated
@@ -1100,6 +1100,8 @@ dependencies = [
|
|||||||
"futures-util",
|
"futures-util",
|
||||||
"glob",
|
"glob",
|
||||||
"image",
|
"image",
|
||||||
|
"notify",
|
||||||
|
"notify-debouncer-mini",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"qrcode",
|
"qrcode",
|
||||||
"serde",
|
"serde",
|
||||||
@@ -1379,6 +1381,15 @@ dependencies = [
|
|||||||
"percent-encoding",
|
"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]]
|
[[package]]
|
||||||
name = "futf"
|
name = "futf"
|
||||||
version = "0.1.5"
|
version = "0.1.5"
|
||||||
@@ -2205,6 +2216,26 @@ dependencies = [
|
|||||||
"cfb",
|
"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]]
|
[[package]]
|
||||||
name = "inout"
|
name = "inout"
|
||||||
version = "0.1.4"
|
version = "0.1.4"
|
||||||
@@ -2214,6 +2245,15 @@ dependencies = [
|
|||||||
"generic-array",
|
"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]]
|
[[package]]
|
||||||
name = "interpolate_name"
|
name = "interpolate_name"
|
||||||
version = "0.2.4"
|
version = "0.2.4"
|
||||||
@@ -2379,6 +2419,26 @@ dependencies = [
|
|||||||
"unicode-segmentation",
|
"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]]
|
[[package]]
|
||||||
name = "kuchikiki"
|
name = "kuchikiki"
|
||||||
version = "0.8.8-speedreader"
|
version = "0.8.8-speedreader"
|
||||||
@@ -2603,6 +2663,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "78bed444cc8a2160f01cbcf811ef18cac863ad68ae8ca62092e8db51d51c761c"
|
checksum = "78bed444cc8a2160f01cbcf811ef18cac863ad68ae8ca62092e8db51d51c761c"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
|
"log",
|
||||||
"wasi 0.11.1+wasi-snapshot-preview1",
|
"wasi 0.11.1+wasi-snapshot-preview1",
|
||||||
"windows-sys 0.59.0",
|
"windows-sys 0.59.0",
|
||||||
]
|
]
|
||||||
@@ -2708,6 +2769,46 @@ version = "0.3.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8"
|
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]]
|
[[package]]
|
||||||
name = "num-bigint"
|
name = "num-bigint"
|
||||||
version = "0.4.6"
|
version = "0.4.6"
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ once_cell = "1.19"
|
|||||||
urlencoding = "2.1"
|
urlencoding = "2.1"
|
||||||
qrcode = "0.14"
|
qrcode = "0.14"
|
||||||
image = "0.25"
|
image = "0.25"
|
||||||
|
notify = "7.0"
|
||||||
|
notify-debouncer-mini = "0.5"
|
||||||
|
|
||||||
[profile.dev]
|
[profile.dev]
|
||||||
incremental = true
|
incremental = true
|
||||||
|
|||||||
455
packages/editor-app/src-tauri/src/commands/build.rs
Normal file
455
packages/editor-app/src-tauri/src/commands/build.rs
Normal file
@@ -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<Vec<String>>,
|
||||||
|
) -> Result<u32, String> {
|
||||||
|
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<Vec<String>>,
|
||||||
|
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<String>,
|
||||||
|
/// 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<String>,
|
||||||
|
/// Project root for resolving imports | 项目根目录
|
||||||
|
pub project_root: String,
|
||||||
|
/// Define replacements | 宏定义替换
|
||||||
|
pub define: Option<std::collections::HashMap<String, String>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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<String>,
|
||||||
|
/// Output file size in bytes | 输出文件大小(字节)
|
||||||
|
pub output_size: Option<u64>,
|
||||||
|
/// Error message if failed | 失败时的错误信息
|
||||||
|
pub error: Option<String>,
|
||||||
|
/// Warnings | 警告
|
||||||
|
pub warnings: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bundle JavaScript/TypeScript files using esbuild.
|
||||||
|
/// 使用 esbuild 打包 JavaScript/TypeScript 文件。
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn bundle_scripts(options: BundleOptions) -> Result<BundleResult, String> {
|
||||||
|
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<String> = 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<String> = 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<String>,
|
||||||
|
body_content: Option<String>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let scripts_html: String = scripts
|
||||||
|
.iter()
|
||||||
|
.map(|s| format!(r#" <script src="{}"></script>"#, s))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("\n");
|
||||||
|
|
||||||
|
let body = body_content.unwrap_or_else(|| {
|
||||||
|
r#" <canvas id="game-canvas" style="width: 100%; height: 100%;"></canvas>"#.to_string()
|
||||||
|
});
|
||||||
|
|
||||||
|
let html = format!(
|
||||||
|
r#"<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>{}</title>
|
||||||
|
<style>
|
||||||
|
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
|
||||||
|
html, body {{ width: 100%; height: 100%; overflow: hidden; background: #000; }}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
{}
|
||||||
|
{}
|
||||||
|
</body>
|
||||||
|
</html>"#,
|
||||||
|
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<u64, String> {
|
||||||
|
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<u64, String> {
|
||||||
|
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<u64, String> {
|
||||||
|
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<String, String> {
|
||||||
|
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<String>,
|
||||||
|
recursive: bool,
|
||||||
|
) -> Result<Vec<String>, 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<String>,
|
||||||
|
) -> 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<String, String> {
|
||||||
|
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))
|
||||||
|
}
|
||||||
389
packages/editor-app/src-tauri/src/commands/compiler.rs
Normal file
389
packages/editor-app/src-tauri/src/commands/compiler.rs
Normal file
@@ -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<String>,
|
||||||
|
/// 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<String>,
|
||||||
|
/// Line number | 行号
|
||||||
|
pub line: Option<u32>,
|
||||||
|
/// Column number | 列号
|
||||||
|
pub column: Option<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compilation result.
|
||||||
|
/// 编译结果。
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct CompileResult {
|
||||||
|
/// Whether compilation succeeded | 是否编译成功
|
||||||
|
pub success: bool,
|
||||||
|
/// Compilation errors | 编译错误
|
||||||
|
pub errors: Vec<CompileError>,
|
||||||
|
/// Output file path (if successful) | 输出文件路径(如果成功)
|
||||||
|
pub output_path: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compile TypeScript using esbuild.
|
||||||
|
/// 使用 esbuild 编译 TypeScript。
|
||||||
|
///
|
||||||
|
/// # Arguments | 参数
|
||||||
|
/// * `options` - Compilation options | 编译选项
|
||||||
|
///
|
||||||
|
/// # Returns | 返回
|
||||||
|
/// Compilation result | 编译结果
|
||||||
|
#[command]
|
||||||
|
pub async fn compile_typescript(options: CompileOptions) -> Result<CompileResult, String> {
|
||||||
|
// 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<Event, notify::Error>| {
|
||||||
|
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<String> = 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<String>,
|
||||||
|
) -> 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<String, String> {
|
||||||
|
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<CompileError> {
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -1,17 +1,25 @@
|
|||||||
//! Command modules
|
//! Command modules.
|
||||||
|
//! 命令模块。
|
||||||
//!
|
//!
|
||||||
//! All Tauri commands organized by domain.
|
//! All Tauri commands organized by domain.
|
||||||
|
//! 所有按领域组织的 Tauri 命令。
|
||||||
|
|
||||||
|
pub mod build;
|
||||||
|
pub mod compiler;
|
||||||
pub mod dialog;
|
pub mod dialog;
|
||||||
pub mod file_system;
|
pub mod file_system;
|
||||||
|
pub mod modules;
|
||||||
pub mod plugin;
|
pub mod plugin;
|
||||||
pub mod profiler;
|
pub mod profiler;
|
||||||
pub mod project;
|
pub mod project;
|
||||||
pub mod system;
|
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 dialog::*;
|
||||||
pub use file_system::*;
|
pub use file_system::*;
|
||||||
|
pub use modules::*;
|
||||||
pub use plugin::*;
|
pub use plugin::*;
|
||||||
pub use profiler::*;
|
pub use profiler::*;
|
||||||
pub use project::*;
|
pub use project::*;
|
||||||
|
|||||||
175
packages/editor-app/src-tauri/src/commands/modules.rs
Normal file
175
packages/editor-app/src-tauri/src/commands/modules.rs
Normal file
@@ -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<ModuleIndexEntry>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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<String>,
|
||||||
|
#[serde(rename = "isCore")]
|
||||||
|
pub is_core: bool,
|
||||||
|
pub category: String,
|
||||||
|
/// JS bundle size in bytes | JS 包大小(字节)
|
||||||
|
#[serde(rename = "jsSize")]
|
||||||
|
pub js_size: Option<u64>,
|
||||||
|
/// Whether this module requires WASM | 是否需要 WASM
|
||||||
|
#[serde(rename = "requiresWasm")]
|
||||||
|
pub requires_wasm: Option<bool>,
|
||||||
|
/// WASM file size in bytes | WASM 文件大小(字节)
|
||||||
|
#[serde(rename = "wasmSize")]
|
||||||
|
pub wasm_size: Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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<PathBuf, String> {
|
||||||
|
// 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<ModuleIndex, String> {
|
||||||
|
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<serde_json::Value, String> {
|
||||||
|
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<String, String> {
|
||||||
|
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())
|
||||||
|
}
|
||||||
@@ -12,14 +12,15 @@ use std::collections::HashMap;
|
|||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
use tauri::Manager;
|
use tauri::Manager;
|
||||||
|
|
||||||
use state::{ProfilerState, ProjectPaths};
|
use state::{ProfilerState, ProjectPaths, ScriptWatcherState};
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
// Initialize shared state
|
// Initialize shared state | 初始化共享状态
|
||||||
let project_paths: ProjectPaths = Arc::new(Mutex::new(HashMap::new()));
|
let project_paths: ProjectPaths = Arc::new(Mutex::new(HashMap::new()));
|
||||||
let project_paths_for_protocol = Arc::clone(&project_paths);
|
let project_paths_for_protocol = Arc::clone(&project_paths);
|
||||||
|
|
||||||
let profiler_state = ProfilerState::new();
|
let profiler_state = ProfilerState::new();
|
||||||
|
let script_watcher_state = ScriptWatcherState::new();
|
||||||
|
|
||||||
// Build and run the Tauri application
|
// Build and run the Tauri application
|
||||||
tauri::Builder::default()
|
tauri::Builder::default()
|
||||||
@@ -34,10 +35,11 @@ fn main() {
|
|||||||
.register_uri_scheme_protocol("project", move |_app, request| {
|
.register_uri_scheme_protocol("project", move |_app, request| {
|
||||||
handle_project_protocol(request, &project_paths_for_protocol)
|
handle_project_protocol(request, &project_paths_for_protocol)
|
||||||
})
|
})
|
||||||
// Setup application state
|
// Setup application state | 设置应用状态
|
||||||
.setup(move |app| {
|
.setup(move |app| {
|
||||||
app.manage(project_paths);
|
app.manage(project_paths);
|
||||||
app.manage(profiler_state);
|
app.manage(profiler_state);
|
||||||
|
app.manage(script_watcher_state);
|
||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
// Register all commands
|
// Register all commands
|
||||||
@@ -85,6 +87,24 @@ fn main() {
|
|||||||
commands::stop_local_server,
|
commands::stop_local_server,
|
||||||
commands::get_local_ip,
|
commands::get_local_ip,
|
||||||
commands::generate_qrcode,
|
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!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running tauri application");
|
.expect("error while running tauri application");
|
||||||
|
|||||||
@@ -1,17 +1,52 @@
|
|||||||
//! Application state definitions
|
//! Application state definitions.
|
||||||
|
//! 应用状态定义。
|
||||||
//!
|
//!
|
||||||
//! Centralized state management for the Tauri application.
|
//! Centralized state management for the Tauri application.
|
||||||
|
//! Tauri 应用的集中状态管理。
|
||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
use tokio::sync::Mutex as TokioMutex;
|
use tokio::sync::Mutex as TokioMutex;
|
||||||
use crate::profiler_ws::ProfilerServer;
|
use crate::profiler_ws::ProfilerServer;
|
||||||
|
|
||||||
/// Project paths state
|
/// Project paths state.
|
||||||
|
/// 项目路径状态。
|
||||||
///
|
///
|
||||||
/// Stores the current project path and other path-related information.
|
/// Stores the current project path and other path-related information.
|
||||||
|
/// 存储当前项目路径和其他路径相关信息。
|
||||||
pub type ProjectPaths = Arc<Mutex<HashMap<String, String>>>;
|
pub type ProjectPaths = Arc<Mutex<HashMap<String, String>>>;
|
||||||
|
|
||||||
|
/// Script watcher state.
|
||||||
|
/// 脚本监视器状态。
|
||||||
|
///
|
||||||
|
/// Manages file watchers for hot reload functionality.
|
||||||
|
/// 管理用于热重载功能的文件监视器。
|
||||||
|
pub struct ScriptWatcherState {
|
||||||
|
/// Active watchers keyed by project path | 按项目路径索引的活动监视器
|
||||||
|
pub watchers: Arc<TokioMutex<HashMap<String, WatcherHandle>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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
|
/// Profiler server state
|
||||||
///
|
///
|
||||||
/// Manages the lifecycle of the WebSocket profiler server.
|
/// Manages the lifecycle of the WebSocket profiler server.
|
||||||
|
|||||||
@@ -24,7 +24,8 @@ import {
|
|||||||
ICompilerRegistry,
|
ICompilerRegistry,
|
||||||
InspectorRegistry,
|
InspectorRegistry,
|
||||||
INotification,
|
INotification,
|
||||||
CommandManager
|
CommandManager,
|
||||||
|
BuildService
|
||||||
} from '@esengine/editor-core';
|
} from '@esengine/editor-core';
|
||||||
import type { IDialogExtended } from './services/TauriDialogService';
|
import type { IDialogExtended } from './services/TauriDialogService';
|
||||||
import { GlobalBlackboardService } from '@esengine/behavior-tree';
|
import { GlobalBlackboardService } from '@esengine/behavior-tree';
|
||||||
@@ -42,6 +43,7 @@ import { AboutDialog } from './components/AboutDialog';
|
|||||||
import { ErrorDialog } from './components/ErrorDialog';
|
import { ErrorDialog } from './components/ErrorDialog';
|
||||||
import { ConfirmDialog } from './components/ConfirmDialog';
|
import { ConfirmDialog } from './components/ConfirmDialog';
|
||||||
import { PluginGeneratorWindow } from './components/PluginGeneratorWindow';
|
import { PluginGeneratorWindow } from './components/PluginGeneratorWindow';
|
||||||
|
import { BuildSettingsWindow } from './components/BuildSettingsWindow';
|
||||||
import { ToastProvider, useToast } from './components/Toast';
|
import { ToastProvider, useToast } from './components/Toast';
|
||||||
import { TitleBar } from './components/TitleBar';
|
import { TitleBar } from './components/TitleBar';
|
||||||
import { MainToolbar } from './components/MainToolbar';
|
import { MainToolbar } from './components/MainToolbar';
|
||||||
@@ -95,6 +97,7 @@ function App() {
|
|||||||
const [sceneManager, setSceneManager] = useState<SceneManagerService | null>(null);
|
const [sceneManager, setSceneManager] = useState<SceneManagerService | null>(null);
|
||||||
const [notification, setNotification] = useState<INotification | null>(null);
|
const [notification, setNotification] = useState<INotification | null>(null);
|
||||||
const [dialog, setDialog] = useState<IDialogExtended | null>(null);
|
const [dialog, setDialog] = useState<IDialogExtended | null>(null);
|
||||||
|
const [buildService, setBuildService] = useState<BuildService | null>(null);
|
||||||
const [commandManager] = useState(() => new CommandManager());
|
const [commandManager] = useState(() => new CommandManager());
|
||||||
const { t, locale, changeLocale } = useLocale();
|
const { t, locale, changeLocale } = useLocale();
|
||||||
|
|
||||||
@@ -117,6 +120,7 @@ function App() {
|
|||||||
showSettings, setShowSettings,
|
showSettings, setShowSettings,
|
||||||
showAbout, setShowAbout,
|
showAbout, setShowAbout,
|
||||||
showPluginGenerator, setShowPluginGenerator,
|
showPluginGenerator, setShowPluginGenerator,
|
||||||
|
showBuildSettings, setShowBuildSettings,
|
||||||
errorDialog, setErrorDialog,
|
errorDialog, setErrorDialog,
|
||||||
confirmDialog, setConfirmDialog
|
confirmDialog, setConfirmDialog
|
||||||
} = useDialogStore();
|
} = useDialogStore();
|
||||||
@@ -285,6 +289,7 @@ function App() {
|
|||||||
setSceneManager(services.sceneManager);
|
setSceneManager(services.sceneManager);
|
||||||
setNotification(services.notification);
|
setNotification(services.notification);
|
||||||
setDialog(services.dialog as IDialogExtended);
|
setDialog(services.dialog as IDialogExtended);
|
||||||
|
setBuildService(services.buildService);
|
||||||
setStatus(t('header.status.ready'));
|
setStatus(t('header.status.ready'));
|
||||||
|
|
||||||
// Check for updates on startup (after 3 seconds)
|
// Check for updates on startup (after 3 seconds)
|
||||||
@@ -768,7 +773,7 @@ function App() {
|
|||||||
let content: React.ReactNode;
|
let content: React.ReactNode;
|
||||||
if (panelDesc.component) {
|
if (panelDesc.component) {
|
||||||
const Component = panelDesc.component;
|
const Component = panelDesc.component;
|
||||||
content = <Component projectPath={currentProjectPath} />;
|
content = <Component projectPath={currentProjectPath} locale={locale} />;
|
||||||
} else if (panelDesc.render) {
|
} else if (panelDesc.render) {
|
||||||
content = panelDesc.render();
|
content = panelDesc.render();
|
||||||
}
|
}
|
||||||
@@ -883,6 +888,7 @@ function App() {
|
|||||||
onOpenAbout={handleOpenAbout}
|
onOpenAbout={handleOpenAbout}
|
||||||
onCreatePlugin={handleCreatePlugin}
|
onCreatePlugin={handleCreatePlugin}
|
||||||
onReloadPlugins={handleReloadPlugins}
|
onReloadPlugins={handleReloadPlugins}
|
||||||
|
onOpenBuildSettings={() => setShowBuildSettings(true)}
|
||||||
/>
|
/>
|
||||||
<MainToolbar
|
<MainToolbar
|
||||||
locale={locale}
|
locale={locale}
|
||||||
@@ -971,6 +977,16 @@ function App() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{showBuildSettings && (
|
||||||
|
<BuildSettingsWindow
|
||||||
|
onClose={() => setShowBuildSettings(false)}
|
||||||
|
projectPath={currentProjectPath || undefined}
|
||||||
|
locale={locale}
|
||||||
|
buildService={buildService || undefined}
|
||||||
|
sceneManager={sceneManager || undefined}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{errorDialog && (
|
{errorDialog && (
|
||||||
<ErrorDialog
|
<ErrorDialog
|
||||||
title={errorDialog.title}
|
title={errorDialog.title}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ interface DialogState {
|
|||||||
showSettings: boolean;
|
showSettings: boolean;
|
||||||
showAbout: boolean;
|
showAbout: boolean;
|
||||||
showPluginGenerator: boolean;
|
showPluginGenerator: boolean;
|
||||||
|
showBuildSettings: boolean;
|
||||||
errorDialog: ErrorDialogData | null;
|
errorDialog: ErrorDialogData | null;
|
||||||
confirmDialog: ConfirmDialogData | null;
|
confirmDialog: ConfirmDialogData | null;
|
||||||
|
|
||||||
@@ -22,6 +23,7 @@ interface DialogState {
|
|||||||
setShowSettings: (show: boolean) => void;
|
setShowSettings: (show: boolean) => void;
|
||||||
setShowAbout: (show: boolean) => void;
|
setShowAbout: (show: boolean) => void;
|
||||||
setShowPluginGenerator: (show: boolean) => void;
|
setShowPluginGenerator: (show: boolean) => void;
|
||||||
|
setShowBuildSettings: (show: boolean) => void;
|
||||||
setErrorDialog: (data: ErrorDialogData | null) => void;
|
setErrorDialog: (data: ErrorDialogData | null) => void;
|
||||||
setConfirmDialog: (data: ConfirmDialogData | null) => void;
|
setConfirmDialog: (data: ConfirmDialogData | null) => void;
|
||||||
closeAllDialogs: () => void;
|
closeAllDialogs: () => void;
|
||||||
@@ -34,6 +36,7 @@ export const useDialogStore = create<DialogState>((set) => ({
|
|||||||
showSettings: false,
|
showSettings: false,
|
||||||
showAbout: false,
|
showAbout: false,
|
||||||
showPluginGenerator: false,
|
showPluginGenerator: false,
|
||||||
|
showBuildSettings: false,
|
||||||
errorDialog: null,
|
errorDialog: null,
|
||||||
confirmDialog: null,
|
confirmDialog: null,
|
||||||
|
|
||||||
@@ -43,6 +46,7 @@ export const useDialogStore = create<DialogState>((set) => ({
|
|||||||
setShowSettings: (show) => set({ showSettings: show }),
|
setShowSettings: (show) => set({ showSettings: show }),
|
||||||
setShowAbout: (show) => set({ showAbout: show }),
|
setShowAbout: (show) => set({ showAbout: show }),
|
||||||
setShowPluginGenerator: (show) => set({ showPluginGenerator: show }),
|
setShowPluginGenerator: (show) => set({ showPluginGenerator: show }),
|
||||||
|
setShowBuildSettings: (show) => set({ showBuildSettings: show }),
|
||||||
setErrorDialog: (data) => set({ errorDialog: data }),
|
setErrorDialog: (data) => set({ errorDialog: data }),
|
||||||
setConfirmDialog: (data) => set({ confirmDialog: data }),
|
setConfirmDialog: (data) => set({ confirmDialog: data }),
|
||||||
|
|
||||||
@@ -53,6 +57,7 @@ export const useDialogStore = create<DialogState>((set) => ({
|
|||||||
showSettings: false,
|
showSettings: false,
|
||||||
showAbout: false,
|
showAbout: false,
|
||||||
showPluginGenerator: false,
|
showPluginGenerator: false,
|
||||||
|
showBuildSettings: false,
|
||||||
errorDialog: null,
|
errorDialog: null,
|
||||||
confirmDialog: null
|
confirmDialog: null
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -13,8 +13,8 @@ import { GizmoPlugin } from '../../plugins/builtin/GizmoPlugin';
|
|||||||
import { SceneInspectorPlugin } from '../../plugins/builtin/SceneInspectorPlugin';
|
import { SceneInspectorPlugin } from '../../plugins/builtin/SceneInspectorPlugin';
|
||||||
import { ProfilerPlugin } from '../../plugins/builtin/ProfilerPlugin';
|
import { ProfilerPlugin } from '../../plugins/builtin/ProfilerPlugin';
|
||||||
import { EditorAppearancePlugin } from '../../plugins/builtin/EditorAppearancePlugin';
|
import { EditorAppearancePlugin } from '../../plugins/builtin/EditorAppearancePlugin';
|
||||||
import { PluginConfigPlugin } from '../../plugins/builtin/PluginConfigPlugin';
|
|
||||||
import { ProjectSettingsPlugin } from '../../plugins/builtin/ProjectSettingsPlugin';
|
import { ProjectSettingsPlugin } from '../../plugins/builtin/ProjectSettingsPlugin';
|
||||||
|
// Note: PluginConfigPlugin removed - module management is now unified in ProjectSettingsPlugin
|
||||||
|
|
||||||
// 统一模块插件(从编辑器包导入完整插件,包含 runtime + editor)
|
// 统一模块插件(从编辑器包导入完整插件,包含 runtime + editor)
|
||||||
import { BehaviorTreePlugin } from '@esengine/behavior-tree-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 { TilemapPlugin } from '@esengine/tilemap-editor';
|
||||||
import { UIPlugin } from '@esengine/ui-editor';
|
import { UIPlugin } from '@esengine/ui-editor';
|
||||||
import { BlueprintPlugin } from '@esengine/blueprint-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 {
|
export class PluginInstaller {
|
||||||
/**
|
/**
|
||||||
@@ -34,13 +37,12 @@ export class PluginInstaller {
|
|||||||
{ name: 'SceneInspectorPlugin', plugin: SceneInspectorPlugin },
|
{ name: 'SceneInspectorPlugin', plugin: SceneInspectorPlugin },
|
||||||
{ name: 'ProfilerPlugin', plugin: ProfilerPlugin },
|
{ name: 'ProfilerPlugin', plugin: ProfilerPlugin },
|
||||||
{ name: 'EditorAppearancePlugin', plugin: EditorAppearancePlugin },
|
{ name: 'EditorAppearancePlugin', plugin: EditorAppearancePlugin },
|
||||||
{ name: 'PluginConfigPlugin', plugin: PluginConfigPlugin },
|
|
||||||
{ name: 'ProjectSettingsPlugin', plugin: ProjectSettingsPlugin },
|
{ name: 'ProjectSettingsPlugin', plugin: ProjectSettingsPlugin },
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const { name, plugin } of builtinPlugins) {
|
for (const { name, plugin } of builtinPlugins) {
|
||||||
if (!plugin || !plugin.descriptor) {
|
if (!plugin || !plugin.manifest) {
|
||||||
console.error(`[PluginInstaller] ${name} is invalid: missing descriptor`, plugin);
|
console.error(`[PluginInstaller] ${name} is invalid: missing manifest`, plugin);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
@@ -52,20 +54,23 @@ export class PluginInstaller {
|
|||||||
|
|
||||||
// 统一模块插件(runtime + editor)
|
// 统一模块插件(runtime + editor)
|
||||||
const modulePlugins = [
|
const modulePlugins = [
|
||||||
|
{ name: 'SpritePlugin', plugin: SpritePlugin },
|
||||||
{ name: 'TilemapPlugin', plugin: TilemapPlugin },
|
{ name: 'TilemapPlugin', plugin: TilemapPlugin },
|
||||||
{ name: 'UIPlugin', plugin: UIPlugin },
|
{ name: 'UIPlugin', plugin: UIPlugin },
|
||||||
{ name: 'BehaviorTreePlugin', plugin: BehaviorTreePlugin },
|
{ name: 'BehaviorTreePlugin', plugin: BehaviorTreePlugin },
|
||||||
{ name: 'Physics2DPlugin', plugin: Physics2DPlugin },
|
{ name: 'Physics2DPlugin', plugin: Physics2DPlugin },
|
||||||
{ name: 'BlueprintPlugin', plugin: BlueprintPlugin },
|
{ name: 'BlueprintPlugin', plugin: BlueprintPlugin },
|
||||||
|
{ name: 'MaterialPlugin', plugin: MaterialPlugin },
|
||||||
|
{ name: 'ShaderEditorPlugin', plugin: ShaderEditorPlugin },
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const { name, plugin } of modulePlugins) {
|
for (const { name, plugin } of modulePlugins) {
|
||||||
if (!plugin || !plugin.descriptor) {
|
if (!plugin || !plugin.manifest) {
|
||||||
console.error(`[PluginInstaller] ${name} is invalid: missing descriptor`, plugin);
|
console.error(`[PluginInstaller] ${name} is invalid: missing manifest`, plugin);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
// 详细日志,检查 editorModule 是否存在
|
// 详细日志,检查 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 {
|
try {
|
||||||
pluginManager.register(plugin);
|
pluginManager.register(plugin);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Core, ComponentRegistry as CoreComponentRegistry } from '@esengine/ecs-framework';
|
import { Core, ComponentRegistry as CoreComponentRegistry } from '@esengine/ecs-framework';
|
||||||
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
import {
|
import {
|
||||||
UIRegistry,
|
UIRegistry,
|
||||||
MessageHub,
|
MessageHub,
|
||||||
@@ -27,8 +28,18 @@ import {
|
|||||||
IDialogService,
|
IDialogService,
|
||||||
IFileSystemService,
|
IFileSystemService,
|
||||||
CompilerRegistry,
|
CompilerRegistry,
|
||||||
ICompilerRegistry
|
ICompilerRegistry,
|
||||||
|
IViewportService_ID,
|
||||||
|
IPreviewSceneService,
|
||||||
|
IEditorViewportServiceIdentifier,
|
||||||
|
PreviewSceneService,
|
||||||
|
EditorViewportService,
|
||||||
|
BuildService,
|
||||||
|
WebBuildPipeline,
|
||||||
|
WeChatBuildPipeline,
|
||||||
|
moduleRegistry
|
||||||
} from '@esengine/editor-core';
|
} from '@esengine/editor-core';
|
||||||
|
import { ViewportService } from '../../services/ViewportService';
|
||||||
import { TransformComponent } from '@esengine/engine-core';
|
import { TransformComponent } from '@esengine/engine-core';
|
||||||
import { SpriteComponent, SpriteAnimatorComponent } from '@esengine/sprite';
|
import { SpriteComponent, SpriteAnimatorComponent } from '@esengine/sprite';
|
||||||
import { CameraComponent } from '@esengine/camera';
|
import { CameraComponent } from '@esengine/camera';
|
||||||
@@ -65,6 +76,8 @@ import {
|
|||||||
AnimationClipsFieldEditor
|
AnimationClipsFieldEditor
|
||||||
} from '../../infrastructure/field-editors';
|
} from '../../infrastructure/field-editors';
|
||||||
import { TransformComponentInspector } from '../../components/inspectors/component-inspectors/TransformComponentInspector';
|
import { TransformComponentInspector } from '../../components/inspectors/component-inspectors/TransformComponentInspector';
|
||||||
|
import { buildFileSystem } from '../../services/BuildFileSystemService';
|
||||||
|
import { TauriModuleFileSystem } from '../../services/TauriModuleFileSystem';
|
||||||
|
|
||||||
export interface EditorServices {
|
export interface EditorServices {
|
||||||
uiRegistry: UIRegistry;
|
uiRegistry: UIRegistry;
|
||||||
@@ -90,6 +103,7 @@ export interface EditorServices {
|
|||||||
inspectorRegistry: InspectorRegistry;
|
inspectorRegistry: InspectorRegistry;
|
||||||
propertyRendererRegistry: PropertyRendererRegistry;
|
propertyRendererRegistry: PropertyRendererRegistry;
|
||||||
fieldEditorRegistry: FieldEditorRegistry;
|
fieldEditorRegistry: FieldEditorRegistry;
|
||||||
|
buildService: BuildService;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ServiceRegistry {
|
export class ServiceRegistry {
|
||||||
@@ -172,6 +186,22 @@ export class ServiceRegistry {
|
|||||||
Core.services.registerInstance(IDialogService, dialog);
|
Core.services.registerInstance(IDialogService, dialog);
|
||||||
Core.services.registerInstance(IFileSystemService, fileSystem);
|
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();
|
const inspectorRegistry = new InspectorRegistry();
|
||||||
Core.services.registerInstance(InspectorRegistry, inspectorRegistry);
|
Core.services.registerInstance(InspectorRegistry, inspectorRegistry);
|
||||||
Core.services.registerInstance(IInspectorRegistry, inspectorRegistry); // Symbol 注册用于跨包插件访问
|
Core.services.registerInstance(IInspectorRegistry, inspectorRegistry); // Symbol 注册用于跨包插件访问
|
||||||
@@ -204,6 +234,43 @@ export class ServiceRegistry {
|
|||||||
// Register component inspectors
|
// Register component inspectors
|
||||||
componentInspectorRegistry.register(new TransformComponentInspector());
|
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<string>('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
|
// Register default scene template - creates default camera
|
||||||
this.registerDefaultSceneTemplate();
|
this.registerDefaultSceneTemplate();
|
||||||
@@ -231,7 +298,8 @@ export class ServiceRegistry {
|
|||||||
notification,
|
notification,
|
||||||
inspectorRegistry,
|
inspectorRegistry,
|
||||||
propertyRendererRegistry,
|
propertyRendererRegistry,
|
||||||
fieldEditorRegistry
|
fieldEditorRegistry,
|
||||||
|
buildService
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
897
packages/editor-app/src/components/BuildSettingsPanel.tsx
Normal file
897
packages/editor-app/src/components/BuildSettingsPanel.tsx
Normal file
@@ -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: <Monitor size={16} />, available: true },
|
||||||
|
{ platform: 'macos', label: 'macOS', icon: <Apple size={16} />, available: true },
|
||||||
|
{ platform: 'linux', label: 'Linux', icon: <Server size={16} />, available: true },
|
||||||
|
{ platform: 'android', label: 'Android', icon: <Smartphone size={16} />, available: true },
|
||||||
|
{ platform: 'ios', label: 'iOS', icon: <Smartphone size={16} />, available: true },
|
||||||
|
{ platform: 'web', label: 'Web', icon: <Globe size={16} />, available: true },
|
||||||
|
{ platform: 'wechat-minigame', label: 'WeChat Mini Game', icon: <Gamepad2 size={16} />, 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<BuildProfile[]>([
|
||||||
|
{ 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<PlatformType>('web');
|
||||||
|
const [selectedProfile, setSelectedProfile] = useState<BuildProfile | null>(profiles[0] || null);
|
||||||
|
const [settings, setSettings] = useState<BuildSettings>(DEFAULT_SETTINGS);
|
||||||
|
const [expandedSections, setExpandedSections] = useState<Record<string, boolean>>({
|
||||||
|
sceneList: true,
|
||||||
|
scriptingDefines: true,
|
||||||
|
platformSettings: true,
|
||||||
|
playerSettings: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Build state | 构建状态
|
||||||
|
const [isBuilding, setIsBuilding] = useState(false);
|
||||||
|
const [buildProgress, setBuildProgress] = useState<BuildProgress | null>(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<AbortController | null>(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<PlatformType, BuildPlatform> = {
|
||||||
|
'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, keyof typeof i18n.en> = {
|
||||||
|
[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 (
|
||||||
|
<div className="build-settings-panel">
|
||||||
|
{/* Header Tabs | 头部标签 */}
|
||||||
|
<div className="build-settings-header">
|
||||||
|
<div className="build-settings-tabs">
|
||||||
|
<div className="build-settings-tab active">
|
||||||
|
<Package size={14} />
|
||||||
|
{t.buildProfiles}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="build-settings-header-actions">
|
||||||
|
<button className="build-settings-header-btn">{t.playerSettings}</button>
|
||||||
|
<button className="build-settings-header-btn">{t.assetImportOverrides}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Add Profile Bar | 添加配置栏 */}
|
||||||
|
<div className="build-settings-add-bar">
|
||||||
|
<button className="build-settings-add-btn" onClick={handleAddProfile}>
|
||||||
|
<Plus size={14} />
|
||||||
|
{t.addBuildProfile}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Main Content | 主要内容 */}
|
||||||
|
<div className="build-settings-content">
|
||||||
|
{/* Left Sidebar | 左侧边栏 */}
|
||||||
|
<div className="build-settings-sidebar">
|
||||||
|
{/* Platforms Section | 平台部分 */}
|
||||||
|
<div className="build-settings-section">
|
||||||
|
<div className="build-settings-section-header">{t.platforms}</div>
|
||||||
|
<div className="build-settings-platform-list">
|
||||||
|
{PLATFORMS.map(platform => {
|
||||||
|
const isActive = profiles.some(p => p.platform === platform.platform && p.isActive);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={platform.platform}
|
||||||
|
className={`build-settings-platform-item ${selectedPlatform === platform.platform ? 'selected' : ''}`}
|
||||||
|
onClick={() => handlePlatformSelect(platform.platform)}
|
||||||
|
>
|
||||||
|
<span className="build-settings-platform-icon">{platform.icon}</span>
|
||||||
|
<span className="build-settings-platform-label">{platform.label}</span>
|
||||||
|
{isActive && <span className="build-settings-active-badge">{t.active}</span>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Build Profiles Section | 构建配置部分 */}
|
||||||
|
<div className="build-settings-section">
|
||||||
|
<div className="build-settings-section-header">{t.buildProfiles}</div>
|
||||||
|
<div className="build-settings-profile-list">
|
||||||
|
{profiles
|
||||||
|
.filter(p => p.platform === selectedPlatform)
|
||||||
|
.map(profile => (
|
||||||
|
<div
|
||||||
|
key={profile.id}
|
||||||
|
className={`build-settings-profile-item ${selectedProfile?.id === profile.id ? 'selected' : ''}`}
|
||||||
|
onClick={() => handleProfileSelect(profile)}
|
||||||
|
>
|
||||||
|
<span className="build-settings-profile-icon">
|
||||||
|
{currentPlatformConfig?.icon}
|
||||||
|
</span>
|
||||||
|
<span className="build-settings-profile-name">{profile.name}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right Panel | 右侧面板 */}
|
||||||
|
<div className="build-settings-details">
|
||||||
|
{selectedProfile ? (
|
||||||
|
<>
|
||||||
|
{/* Profile Header | 配置头部 */}
|
||||||
|
<div className="build-settings-details-header">
|
||||||
|
<div className="build-settings-details-title">
|
||||||
|
<span className="build-settings-details-icon">
|
||||||
|
{currentPlatformConfig?.icon}
|
||||||
|
</span>
|
||||||
|
<div className="build-settings-details-info">
|
||||||
|
<h3>{selectedProfile.name}</h3>
|
||||||
|
<span>{currentPlatformConfig?.label}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="build-settings-details-actions">
|
||||||
|
<button className="build-settings-btn secondary">{t.switchProfile}</button>
|
||||||
|
<button className="build-settings-btn primary" onClick={handleBuild}>
|
||||||
|
{t.build}
|
||||||
|
<ChevronDown size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Build Data Section | 构建数据部分 */}
|
||||||
|
<div className="build-settings-card">
|
||||||
|
<div className="build-settings-card-header">{t.buildData}</div>
|
||||||
|
|
||||||
|
{/* Scene List | 场景列表 */}
|
||||||
|
<div className="build-settings-field-group">
|
||||||
|
<div
|
||||||
|
className="build-settings-field-header"
|
||||||
|
onClick={() => toggleSection('sceneList')}
|
||||||
|
>
|
||||||
|
{expandedSections.sceneList ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||||
|
<span>{t.sceneList}</span>
|
||||||
|
</div>
|
||||||
|
{expandedSections.sceneList && (
|
||||||
|
<div className="build-settings-field-content">
|
||||||
|
<div className="build-settings-scene-list">
|
||||||
|
{settings.scenes.length === 0 ? (
|
||||||
|
<div className="build-settings-empty-list"></div>
|
||||||
|
) : (
|
||||||
|
settings.scenes.map((scene, index) => (
|
||||||
|
<div key={index} className="build-settings-scene-item">
|
||||||
|
<input type="checkbox" checked={scene.enabled} readOnly />
|
||||||
|
<span>{scene.path}</span>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="build-settings-field-actions">
|
||||||
|
<button className="build-settings-btn text" onClick={handleAddScene}>
|
||||||
|
{t.addOpenScenes}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Scripting Defines | 脚本定义 */}
|
||||||
|
<div className="build-settings-field-group">
|
||||||
|
<div
|
||||||
|
className="build-settings-field-header"
|
||||||
|
onClick={() => toggleSection('scriptingDefines')}
|
||||||
|
>
|
||||||
|
{expandedSections.scriptingDefines ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||||
|
<span>{t.scriptingDefines}</span>
|
||||||
|
</div>
|
||||||
|
{expandedSections.scriptingDefines && (
|
||||||
|
<div className="build-settings-field-content">
|
||||||
|
<div className="build-settings-defines-list">
|
||||||
|
{settings.scriptingDefines.length === 0 ? (
|
||||||
|
<div className="build-settings-empty-text">{t.listIsEmpty}</div>
|
||||||
|
) : (
|
||||||
|
settings.scriptingDefines.map((define, index) => (
|
||||||
|
<div key={index} className="build-settings-define-item">
|
||||||
|
<span>{define}</span>
|
||||||
|
<button onClick={() => handleRemoveDefine(index)}>
|
||||||
|
<Minus size={12} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="build-settings-list-actions">
|
||||||
|
<button onClick={handleAddDefine}><Plus size={14} /></button>
|
||||||
|
<button disabled={settings.scriptingDefines.length === 0}>
|
||||||
|
<Minus size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Platform Settings Section | 平台设置部分 */}
|
||||||
|
<div className="build-settings-card">
|
||||||
|
<div className="build-settings-card-header">{t.platformSettings}</div>
|
||||||
|
|
||||||
|
<div className="build-settings-field-group">
|
||||||
|
<div
|
||||||
|
className="build-settings-field-header"
|
||||||
|
onClick={() => toggleSection('platformSettings')}
|
||||||
|
>
|
||||||
|
{expandedSections.platformSettings ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||||
|
<span>{currentPlatformConfig?.label} Settings</span>
|
||||||
|
</div>
|
||||||
|
{expandedSections.platformSettings && (
|
||||||
|
<div className="build-settings-field-content">
|
||||||
|
<div className="build-settings-form">
|
||||||
|
<div className="build-settings-form-row">
|
||||||
|
<label>{t.developmentBuild}</label>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={settings.developmentBuild}
|
||||||
|
onChange={e => setSettings(prev => ({
|
||||||
|
...prev,
|
||||||
|
developmentBuild: e.target.checked
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="build-settings-form-row">
|
||||||
|
<label>{t.sourceMap}</label>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={settings.sourceMap}
|
||||||
|
onChange={e => setSettings(prev => ({
|
||||||
|
...prev,
|
||||||
|
sourceMap: e.target.checked
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="build-settings-form-row">
|
||||||
|
<label>{t.compressionMethod}</label>
|
||||||
|
<select
|
||||||
|
value={settings.compressionMethod}
|
||||||
|
onChange={e => setSettings(prev => ({
|
||||||
|
...prev,
|
||||||
|
compressionMethod: e.target.value as any
|
||||||
|
}))}
|
||||||
|
>
|
||||||
|
<option value="Default">Default</option>
|
||||||
|
<option value="LZ4">LZ4</option>
|
||||||
|
<option value="LZ4HC">LZ4HC</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="build-settings-form-row">
|
||||||
|
<label>{t.bundleModules}</label>
|
||||||
|
<div className="build-settings-toggle-group">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={settings.bundleModules}
|
||||||
|
onChange={e => setSettings(prev => ({
|
||||||
|
...prev,
|
||||||
|
bundleModules: e.target.checked
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
<span className="build-settings-hint">
|
||||||
|
{settings.bundleModules ? t.bundleModulesHint : t.separateModulesHint}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Player Settings Overrides | 玩家设置覆盖 */}
|
||||||
|
<div className="build-settings-card">
|
||||||
|
<div className="build-settings-card-header">
|
||||||
|
{t.playerSettingsOverrides}
|
||||||
|
<button className="build-settings-more-btn">
|
||||||
|
<Settings size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="build-settings-field-group">
|
||||||
|
<div
|
||||||
|
className="build-settings-field-header"
|
||||||
|
onClick={() => toggleSection('playerSettings')}
|
||||||
|
>
|
||||||
|
{expandedSections.playerSettings ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||||
|
<span>Player Settings</span>
|
||||||
|
</div>
|
||||||
|
{expandedSections.playerSettings && (
|
||||||
|
<div className="build-settings-field-content">
|
||||||
|
<div className="build-settings-form">
|
||||||
|
<div className="build-settings-form-row">
|
||||||
|
<label>{t.companyName}</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={settings.companyName}
|
||||||
|
onChange={e => setSettings(prev => ({
|
||||||
|
...prev,
|
||||||
|
companyName: e.target.value
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="build-settings-form-row">
|
||||||
|
<label>{t.productName}</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={settings.productName}
|
||||||
|
onChange={e => setSettings(prev => ({
|
||||||
|
...prev,
|
||||||
|
productName: e.target.value
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="build-settings-form-row">
|
||||||
|
<label>{t.version}</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={settings.version}
|
||||||
|
onChange={e => setSettings(prev => ({
|
||||||
|
...prev,
|
||||||
|
version: e.target.value
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="build-settings-form-row">
|
||||||
|
<label>{t.defaultIcon}</label>
|
||||||
|
<div className="build-settings-icon-picker">
|
||||||
|
<span>{t.none}</span>
|
||||||
|
<span className="build-settings-icon-hint">(Texture 2D)</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="build-settings-no-selection">
|
||||||
|
<p>Select a platform or build profile</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Build Progress Dialog | 构建进度对话框 */}
|
||||||
|
{showBuildProgress && (
|
||||||
|
<div className="build-progress-overlay">
|
||||||
|
<div className="build-progress-dialog">
|
||||||
|
<div className="build-progress-header">
|
||||||
|
<h3>{t.buildInProgress}</h3>
|
||||||
|
{!isBuilding && (
|
||||||
|
<button
|
||||||
|
className="build-progress-close"
|
||||||
|
onClick={handleCloseBuildProgress}
|
||||||
|
>
|
||||||
|
<X size={16} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="build-progress-content">
|
||||||
|
{/* Status Icon | 状态图标 */}
|
||||||
|
<div className="build-progress-status-icon">
|
||||||
|
{isBuilding ? (
|
||||||
|
<Loader2 size={48} className="build-progress-spinner" />
|
||||||
|
) : buildResult?.success ? (
|
||||||
|
<CheckCircle size={48} className="build-progress-success" />
|
||||||
|
) : (
|
||||||
|
<XCircle size={48} className="build-progress-error" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status Message | 状态消息 */}
|
||||||
|
<div className="build-progress-message">
|
||||||
|
{isBuilding ? (
|
||||||
|
buildProgress?.message || getStatusMessage(buildProgress?.status || BuildStatus.Preparing)
|
||||||
|
) : buildResult?.success ? (
|
||||||
|
t.buildSucceeded
|
||||||
|
) : (
|
||||||
|
t.buildFailed
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Progress Bar | 进度条 */}
|
||||||
|
{isBuilding && buildProgress && (
|
||||||
|
<div className="build-progress-bar-container">
|
||||||
|
<div
|
||||||
|
className="build-progress-bar"
|
||||||
|
style={{ width: `${buildProgress.progress}%` }}
|
||||||
|
/>
|
||||||
|
<span className="build-progress-percent">
|
||||||
|
{Math.round(buildProgress.progress)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Build Result Details | 构建结果详情 */}
|
||||||
|
{!isBuilding && buildResult && (
|
||||||
|
<div className="build-result-details">
|
||||||
|
{buildResult.success && (
|
||||||
|
<>
|
||||||
|
<div className="build-result-row">
|
||||||
|
<span className="build-result-label">{t.outputPath}:</span>
|
||||||
|
<span className="build-result-value">{buildResult.outputPath}</span>
|
||||||
|
</div>
|
||||||
|
<div className="build-result-row">
|
||||||
|
<span className="build-result-label">{t.duration}:</span>
|
||||||
|
<span className="build-result-value">
|
||||||
|
{(buildResult.duration / 1000).toFixed(2)}s
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Error Message | 错误消息 */}
|
||||||
|
{buildResult.error && (
|
||||||
|
<div className="build-result-error">
|
||||||
|
<AlertTriangle size={16} />
|
||||||
|
<span>{buildResult.error}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Warnings | 警告 */}
|
||||||
|
{buildResult.warnings.length > 0 && (
|
||||||
|
<div className="build-result-warnings">
|
||||||
|
<div className="build-result-warnings-header">
|
||||||
|
<AlertTriangle size={14} />
|
||||||
|
<span>{t.warnings} ({buildResult.warnings.length})</span>
|
||||||
|
</div>
|
||||||
|
<ul className="build-result-warnings-list">
|
||||||
|
{buildResult.warnings.map((warning, index) => (
|
||||||
|
<li key={index}>{warning}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions | 操作按钮 */}
|
||||||
|
<div className="build-progress-actions">
|
||||||
|
{isBuilding ? (
|
||||||
|
<button
|
||||||
|
className="build-settings-btn secondary"
|
||||||
|
onClick={handleCancelBuild}
|
||||||
|
>
|
||||||
|
{t.cancel}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
className="build-settings-btn primary"
|
||||||
|
onClick={handleCloseBuildProgress}
|
||||||
|
>
|
||||||
|
{t.close}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default BuildSettingsPanel;
|
||||||
62
packages/editor-app/src/components/BuildSettingsWindow.tsx
Normal file
62
packages/editor-app/src/components/BuildSettingsWindow.tsx
Normal file
@@ -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 (
|
||||||
|
<div className="build-settings-window-overlay">
|
||||||
|
<div className="build-settings-window">
|
||||||
|
<div className="build-settings-window-header">
|
||||||
|
<h2>{t.title}</h2>
|
||||||
|
<button
|
||||||
|
className="build-settings-window-close"
|
||||||
|
onClick={onClose}
|
||||||
|
title="Close"
|
||||||
|
>
|
||||||
|
<X size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="build-settings-window-content">
|
||||||
|
<BuildSettingsPanel
|
||||||
|
projectPath={projectPath}
|
||||||
|
locale={locale}
|
||||||
|
buildService={buildService}
|
||||||
|
sceneManager={sceneManager}
|
||||||
|
onClose={onClose}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default BuildSettingsWindow;
|
||||||
@@ -4,6 +4,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
|
import * as LucideIcons from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
Plus,
|
Plus,
|
||||||
Download,
|
Download,
|
||||||
@@ -68,6 +69,21 @@ interface ContentBrowserProps {
|
|||||||
revealPath?: string | null;
|
revealPath?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据图标名获取 Lucide 图标组件
|
||||||
|
*/
|
||||||
|
function getIconComponent(iconName: string | undefined, size: number = 16): React.ReactNode {
|
||||||
|
if (!iconName) return <File size={size} />;
|
||||||
|
|
||||||
|
const icons = LucideIcons as unknown as Record<string, React.ComponentType<{ size?: number }>>;
|
||||||
|
const IconComponent = icons[iconName];
|
||||||
|
if (IconComponent) {
|
||||||
|
return <IconComponent size={size} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <File size={size} />;
|
||||||
|
}
|
||||||
|
|
||||||
// 获取资产类型显示名称
|
// 获取资产类型显示名称
|
||||||
function getAssetTypeName(asset: AssetItem): string {
|
function getAssetTypeName(asset: AssetItem): string {
|
||||||
if (asset.type === 'folder') return 'Folder';
|
if (asset.type === 'folder') return 'Folder';
|
||||||
@@ -156,7 +172,8 @@ export function ContentBrowser({
|
|||||||
dockInLayout: 'Dock in Layout',
|
dockInLayout: 'Dock in Layout',
|
||||||
noProject: 'No project loaded',
|
noProject: 'No project loaded',
|
||||||
empty: 'This folder is empty',
|
empty: 'This folder is empty',
|
||||||
newFolder: 'New Folder'
|
newFolder: 'New Folder',
|
||||||
|
newPrefix: 'New'
|
||||||
},
|
},
|
||||||
zh: {
|
zh: {
|
||||||
favorites: '收藏夹',
|
favorites: '收藏夹',
|
||||||
@@ -169,7 +186,8 @@ export function ContentBrowser({
|
|||||||
dockInLayout: '停靠到布局',
|
dockInLayout: '停靠到布局',
|
||||||
noProject: '未加载项目',
|
noProject: '未加载项目',
|
||||||
empty: '文件夹为空',
|
empty: '文件夹为空',
|
||||||
newFolder: '新建文件夹'
|
newFolder: '新建文件夹',
|
||||||
|
newPrefix: '新建'
|
||||||
}
|
}
|
||||||
}[locale] || {
|
}[locale] || {
|
||||||
favorites: 'Favorites',
|
favorites: 'Favorites',
|
||||||
@@ -182,7 +200,24 @@ export function ContentBrowser({
|
|||||||
dockInLayout: 'Dock in Layout',
|
dockInLayout: 'Dock in Layout',
|
||||||
noProject: 'No project loaded',
|
noProject: 'No project loaded',
|
||||||
empty: 'This folder is empty',
|
empty: 'This folder is empty',
|
||||||
newFolder: 'New Folder'
|
newFolder: 'New Folder',
|
||||||
|
newPrefix: 'New'
|
||||||
|
};
|
||||||
|
|
||||||
|
// 文件创建模板的 label 本地化映射
|
||||||
|
const templateLabels: Record<string, { en: string; zh: string }> = {
|
||||||
|
'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
|
// Build folder tree - use ref to avoid dependency cycle
|
||||||
@@ -546,8 +581,10 @@ export function ContentBrowser({
|
|||||||
if (templates.length > 0) {
|
if (templates.length > 0) {
|
||||||
items.push({ label: '', separator: true, onClick: () => {} });
|
items.push({ label: '', separator: true, onClick: () => {} });
|
||||||
for (const template of templates) {
|
for (const template of templates) {
|
||||||
|
const localizedLabel = getTemplateLabel(template.label);
|
||||||
items.push({
|
items.push({
|
||||||
label: `New ${template.label}`,
|
label: `${t.newPrefix} ${localizedLabel}`,
|
||||||
|
icon: getIconComponent(template.icon, 16),
|
||||||
onClick: () => {
|
onClick: () => {
|
||||||
setContextMenu(null);
|
setContextMenu(null);
|
||||||
if (currentPath) {
|
if (currentPath) {
|
||||||
|
|||||||
390
packages/editor-app/src/components/EditorViewport.tsx
Normal file
390
packages/editor-app/src/components/EditorViewport.tsx
Normal file
@@ -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<EditorViewportHandle, EditorViewportProps>(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<HTMLCanvasElement>(null);
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [isReady, setIsReady] = useState(false);
|
||||||
|
|
||||||
|
// Camera state
|
||||||
|
const [camera, setCamera] = useState<ViewportCameraConfig>(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 (
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className={`editor-viewport ${className || ''}`}
|
||||||
|
>
|
||||||
|
<canvas
|
||||||
|
ref={canvasRef}
|
||||||
|
className="editor-viewport-canvas"
|
||||||
|
onMouseDown={handleMouseDown}
|
||||||
|
onMouseMove={handleMouseMove}
|
||||||
|
onMouseUp={handleMouseUp}
|
||||||
|
onMouseLeave={handleMouseUp}
|
||||||
|
onWheel={handleWheel}
|
||||||
|
onContextMenu={handleContextMenu}
|
||||||
|
/>
|
||||||
|
{renderOverlays?.()}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default EditorViewport;
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -34,6 +34,7 @@ interface MenuBarProps {
|
|||||||
onOpenAbout?: () => void;
|
onOpenAbout?: () => void;
|
||||||
onCreatePlugin?: () => void;
|
onCreatePlugin?: () => void;
|
||||||
onReloadPlugins?: () => void;
|
onReloadPlugins?: () => void;
|
||||||
|
onOpenBuildSettings?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MenuBar({
|
export function MenuBar({
|
||||||
@@ -55,7 +56,8 @@ export function MenuBar({
|
|||||||
onToggleDevtools,
|
onToggleDevtools,
|
||||||
onOpenAbout,
|
onOpenAbout,
|
||||||
onCreatePlugin,
|
onCreatePlugin,
|
||||||
onReloadPlugins
|
onReloadPlugins,
|
||||||
|
onOpenBuildSettings
|
||||||
}: MenuBarProps) {
|
}: MenuBarProps) {
|
||||||
const [openMenu, setOpenMenu] = useState<string | null>(null);
|
const [openMenu, setOpenMenu] = useState<string | null>(null);
|
||||||
const [pluginMenuItems, setPluginMenuItems] = useState<PluginMenuItem[]>([]);
|
const [pluginMenuItems, setPluginMenuItems] = useState<PluginMenuItem[]>([]);
|
||||||
@@ -129,7 +131,8 @@ export function MenuBar({
|
|||||||
documentation: 'Documentation',
|
documentation: 'Documentation',
|
||||||
checkForUpdates: 'Check for Updates',
|
checkForUpdates: 'Check for Updates',
|
||||||
about: 'About',
|
about: 'About',
|
||||||
devtools: 'Developer Tools'
|
devtools: 'Developer Tools',
|
||||||
|
buildSettings: 'Build Settings'
|
||||||
},
|
},
|
||||||
zh: {
|
zh: {
|
||||||
file: '文件',
|
file: '文件',
|
||||||
@@ -164,7 +167,8 @@ export function MenuBar({
|
|||||||
documentation: '文档',
|
documentation: '文档',
|
||||||
checkForUpdates: '检查更新',
|
checkForUpdates: '检查更新',
|
||||||
about: '关于',
|
about: '关于',
|
||||||
devtools: '开发者工具'
|
devtools: '开发者工具',
|
||||||
|
buildSettings: '构建设置'
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
return translations[locale]?.[key] || key;
|
return translations[locale]?.[key] || key;
|
||||||
@@ -178,6 +182,8 @@ export function MenuBar({
|
|||||||
{ label: t('saveScene'), shortcut: 'Ctrl+S', onClick: onSaveScene },
|
{ label: t('saveScene'), shortcut: 'Ctrl+S', onClick: onSaveScene },
|
||||||
{ label: t('saveSceneAs'), shortcut: 'Ctrl+Shift+S', onClick: onSaveSceneAs },
|
{ label: t('saveSceneAs'), shortcut: 'Ctrl+Shift+S', onClick: onSaveSceneAs },
|
||||||
{ separator: true },
|
{ separator: true },
|
||||||
|
{ label: t('buildSettings'), shortcut: 'Ctrl+Shift+B', onClick: onOpenBuildSettings },
|
||||||
|
{ separator: true },
|
||||||
{ label: t('openProject'), onClick: onOpenProject },
|
{ label: t('openProject'), onClick: onOpenProject },
|
||||||
{ label: t('closeProject'), onClick: onCloseProject },
|
{ label: t('closeProject'), onClick: onCloseProject },
|
||||||
{ separator: true },
|
{ separator: true },
|
||||||
|
|||||||
305
packages/editor-app/src/components/ModuleListSetting.tsx
Normal file
305
packages/editor-app/src/components/ModuleListSetting.tsx
Normal file
@@ -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<ModuleListSettingProps> = ({
|
||||||
|
modules: staticModules,
|
||||||
|
getModules,
|
||||||
|
value,
|
||||||
|
onModulesChange,
|
||||||
|
useBlacklist = false,
|
||||||
|
validateDisable
|
||||||
|
}) => {
|
||||||
|
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(new Set(['Core', 'Rendering']));
|
||||||
|
const [validationError, setValidationError] = useState<{ moduleId: string; message: string } | null>(null);
|
||||||
|
const [loading, setLoading] = useState<string | null>(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<string, ModuleEntry[]>();
|
||||||
|
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<string, ModuleEntry[]>();
|
||||||
|
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 (
|
||||||
|
<div className="module-list-setting">
|
||||||
|
{/* Module categories | 模块分类 */}
|
||||||
|
<div className="module-list-categories">
|
||||||
|
{Array.from(groupedModules.entries()).map(([category, mods]) => (
|
||||||
|
<div key={category} className="module-category-group">
|
||||||
|
<div
|
||||||
|
className="module-category-header"
|
||||||
|
onClick={() => toggleCategory(category)}
|
||||||
|
>
|
||||||
|
{expandedCategories.has(category) ? (
|
||||||
|
<ChevronDown size={14} />
|
||||||
|
) : (
|
||||||
|
<ChevronRight size={14} />
|
||||||
|
)}
|
||||||
|
<span className="module-category-name">{category}</span>
|
||||||
|
<span className="module-category-count">
|
||||||
|
{mods.filter(m => m.enabled).length}/{mods.length}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{expandedCategories.has(category) && (
|
||||||
|
<div className="module-category-items">
|
||||||
|
{mods.map(mod => (
|
||||||
|
<div
|
||||||
|
key={mod.id}
|
||||||
|
className={`module-item ${mod.enabled ? 'enabled' : ''} ${loading === mod.id ? 'loading' : ''}`}
|
||||||
|
>
|
||||||
|
<label className="module-checkbox-label">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={mod.enabled}
|
||||||
|
disabled={mod.isCore || loading === mod.id}
|
||||||
|
onChange={(e) => handleModuleToggle(mod, e.target.checked)}
|
||||||
|
/>
|
||||||
|
<Package size={14} className="module-icon" />
|
||||||
|
<span className="module-name">{mod.displayName}</span>
|
||||||
|
{mod.isCore && (
|
||||||
|
<span className="module-badge core">Core</span>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
{(mod.jsSize || mod.wasmSize) ? (
|
||||||
|
<span className="module-size">
|
||||||
|
{mod.isCore ? '' : '+'}
|
||||||
|
{formatBytes((mod.jsSize || 0) + (mod.wasmSize || 0))}
|
||||||
|
{(mod.wasmSize ?? 0) > 0 && (
|
||||||
|
<span className="module-wasm-indicator" title="Includes WASM">⚡</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Size footer | 大小页脚 */}
|
||||||
|
<div className="module-list-footer">
|
||||||
|
<span className="module-list-size-label">Runtime size:</span>
|
||||||
|
<span className="module-list-size-value">
|
||||||
|
{formatBytes(totalSize)}
|
||||||
|
{totalWasmSize > 0 && (
|
||||||
|
<span className="module-size-breakdown">
|
||||||
|
(JS: {formatBytes(totalJsSize)} + WASM: {formatBytes(totalWasmSize)})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Validation error toast | 验证错误提示 */}
|
||||||
|
{validationError && (
|
||||||
|
<div className="module-validation-error">
|
||||||
|
<AlertCircle size={14} />
|
||||||
|
<span>{validationError.message}</span>
|
||||||
|
<button onClick={() => setValidationError(null)}>Dismiss</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ModuleListSetting;
|
||||||
@@ -11,7 +11,7 @@
|
|||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { Core } from '@esengine/ecs-framework';
|
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 { Check, Lock, Package } from 'lucide-react';
|
||||||
import { NotificationService } from '../services/NotificationService';
|
import { NotificationService } from '../services/NotificationService';
|
||||||
import '../styles/PluginListSetting.css';
|
import '../styles/PluginListSetting.css';
|
||||||
@@ -20,21 +20,17 @@ interface PluginListSettingProps {
|
|||||||
pluginManager: PluginManager;
|
pluginManager: PluginManager;
|
||||||
}
|
}
|
||||||
|
|
||||||
const categoryLabels: Record<PluginCategory, { zh: string; en: string }> = {
|
const categoryLabels: Record<ModuleCategory, { zh: string; en: string }> = {
|
||||||
core: { zh: '核心', en: 'Core' },
|
Core: { zh: '核心', en: 'Core' },
|
||||||
rendering: { zh: '渲染', en: 'Rendering' },
|
Rendering: { zh: '渲染', en: 'Rendering' },
|
||||||
ui: { zh: 'UI', en: 'UI' },
|
Physics: { zh: '物理', en: 'Physics' },
|
||||||
ai: { zh: 'AI', en: 'AI' },
|
AI: { zh: 'AI', en: 'AI' },
|
||||||
physics: { zh: '物理', en: 'Physics' },
|
Audio: { zh: '音频', en: 'Audio' },
|
||||||
audio: { zh: '音频', en: 'Audio' },
|
Networking: { zh: '网络', en: 'Networking' },
|
||||||
networking: { zh: '网络', en: 'Networking' },
|
Other: { zh: '其他', en: 'Other' }
|
||||||
tools: { zh: '工具', en: 'Tools' },
|
|
||||||
scripting: { zh: '脚本', en: 'Scripting' },
|
|
||||||
content: { zh: '内容', en: 'Content' },
|
|
||||||
tilemap: { zh: '瓦片地图', en: 'Tilemap' }
|
|
||||||
};
|
};
|
||||||
|
|
||||||
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) {
|
export function PluginListSetting({ pluginManager }: PluginListSettingProps) {
|
||||||
const [plugins, setPlugins] = useState<RegisteredPlugin[]>([]);
|
const [plugins, setPlugins] = useState<RegisteredPlugin[]>([]);
|
||||||
@@ -56,13 +52,13 @@ export function PluginListSetting({ pluginManager }: PluginListSettingProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleToggle = async (pluginId: string) => {
|
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;
|
if (!plugin) return;
|
||||||
|
|
||||||
const descriptor = plugin.plugin.descriptor;
|
const manifest = plugin.plugin.manifest;
|
||||||
|
|
||||||
// 核心插件不可禁用
|
// 核心插件不可禁用
|
||||||
if (descriptor.isCore) {
|
if (manifest.isCore) {
|
||||||
showWarning('核心插件不可禁用');
|
showWarning('核心插件不可禁用');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -71,14 +67,14 @@ export function PluginListSetting({ pluginManager }: PluginListSettingProps) {
|
|||||||
|
|
||||||
// 检查依赖(启用时)
|
// 检查依赖(启用时)
|
||||||
if (newEnabled) {
|
if (newEnabled) {
|
||||||
const deps = descriptor.dependencies || [];
|
const deps = manifest.dependencies || [];
|
||||||
const missingDeps = deps.filter(dep => {
|
const missingDeps = deps.filter((depId: string) => {
|
||||||
const depPlugin = plugins.find(p => p.plugin.descriptor.id === dep.id);
|
const depPlugin = plugins.find(p => p.plugin.manifest.id === depId);
|
||||||
return depPlugin && !depPlugin.enabled;
|
return depPlugin && !depPlugin.enabled;
|
||||||
});
|
});
|
||||||
|
|
||||||
if (missingDeps.length > 0) {
|
if (missingDeps.length > 0) {
|
||||||
showWarning(`需要先启用依赖插件: ${missingDeps.map(d => d.id).join(', ')}`);
|
showWarning(`需要先启用依赖插件: ${missingDeps.join(', ')}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -100,7 +96,7 @@ export function PluginListSetting({ pluginManager }: PluginListSettingProps) {
|
|||||||
|
|
||||||
// 更新本地状态
|
// 更新本地状态
|
||||||
setPlugins(plugins.map(p => {
|
setPlugins(plugins.map(p => {
|
||||||
if (p.plugin.descriptor.id === pluginId) {
|
if (p.plugin.manifest.id === pluginId) {
|
||||||
return { ...p, enabled: newEnabled };
|
return { ...p, enabled: newEnabled };
|
||||||
}
|
}
|
||||||
return p;
|
return p;
|
||||||
@@ -115,7 +111,7 @@ export function PluginListSetting({ pluginManager }: PluginListSettingProps) {
|
|||||||
const notificationService = Core.services.tryResolve(NotificationService) as NotificationService | null;
|
const notificationService = Core.services.tryResolve(NotificationService) as NotificationService | null;
|
||||||
if (notificationService) {
|
if (notificationService) {
|
||||||
notificationService.show(
|
notificationService.show(
|
||||||
newEnabled ? `已启用插件: ${descriptor.name}` : `已禁用插件: ${descriptor.name}`,
|
newEnabled ? `已启用插件: ${manifest.displayName}` : `已禁用插件: ${manifest.displayName}`,
|
||||||
'success',
|
'success',
|
||||||
2000
|
2000
|
||||||
);
|
);
|
||||||
@@ -135,8 +131,8 @@ export function PluginListSetting({ pluginManager }: PluginListSettingProps) {
|
|||||||
|
|
||||||
// 获取当前启用的插件列表(排除核心插件)
|
// 获取当前启用的插件列表(排除核心插件)
|
||||||
const enabledPlugins = pluginManager.getEnabledPlugins()
|
const enabledPlugins = pluginManager.getEnabledPlugins()
|
||||||
.filter(p => !p.plugin.descriptor.isCore)
|
.filter(p => !p.plugin.manifest.isCore)
|
||||||
.map(p => p.plugin.descriptor.id);
|
.map(p => p.plugin.manifest.id);
|
||||||
|
|
||||||
console.log('[PluginListSetting] Saving enabled plugins:', enabledPlugins);
|
console.log('[PluginListSetting] Saving enabled plugins:', enabledPlugins);
|
||||||
|
|
||||||
@@ -150,13 +146,13 @@ export function PluginListSetting({ pluginManager }: PluginListSettingProps) {
|
|||||||
|
|
||||||
// 按类别分组并排序
|
// 按类别分组并排序
|
||||||
const groupedPlugins = plugins.reduce((acc, plugin) => {
|
const groupedPlugins = plugins.reduce((acc, plugin) => {
|
||||||
const category = plugin.plugin.descriptor.category;
|
const category = plugin.plugin.manifest.category;
|
||||||
if (!acc[category]) {
|
if (!acc[category]) {
|
||||||
acc[category] = [];
|
acc[category] = [];
|
||||||
}
|
}
|
||||||
acc[category].push(plugin);
|
acc[category].push(plugin);
|
||||||
return acc;
|
return acc;
|
||||||
}, {} as Record<PluginCategory, RegisteredPlugin[]>);
|
}, {} as Record<ModuleCategory, RegisteredPlugin[]>);
|
||||||
|
|
||||||
// 按照 categoryOrder 排序
|
// 按照 categoryOrder 排序
|
||||||
const sortedCategories = categoryOrder.filter(cat => groupedPlugins[cat]?.length > 0);
|
const sortedCategories = categoryOrder.filter(cat => groupedPlugins[cat]?.length > 0);
|
||||||
@@ -169,19 +165,19 @@ export function PluginListSetting({ pluginManager }: PluginListSettingProps) {
|
|||||||
{categoryLabels[category]?.zh || category}
|
{categoryLabels[category]?.zh || category}
|
||||||
</div>
|
</div>
|
||||||
<div className="plugin-list">
|
<div className="plugin-list">
|
||||||
{groupedPlugins[category].map(plugin => {
|
{groupedPlugins[category]?.map(plugin => {
|
||||||
const descriptor = plugin.plugin.descriptor;
|
const manifest = plugin.plugin.manifest;
|
||||||
const hasRuntime = !!plugin.plugin.runtimeModule;
|
const hasRuntime = !!plugin.plugin.runtimeModule;
|
||||||
const hasEditor = !!plugin.plugin.editorModule;
|
const hasEditor = !!plugin.plugin.editorModule;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={descriptor.id}
|
key={manifest.id}
|
||||||
className={`plugin-item ${plugin.enabled ? 'enabled' : ''} ${descriptor.isCore ? 'core' : ''}`}
|
className={`plugin-item ${plugin.enabled ? 'enabled' : ''} ${manifest.isCore ? 'core' : ''}`}
|
||||||
onClick={() => handleToggle(descriptor.id)}
|
onClick={() => handleToggle(manifest.id)}
|
||||||
>
|
>
|
||||||
<div className="plugin-checkbox">
|
<div className="plugin-checkbox">
|
||||||
{descriptor.isCore ? (
|
{manifest.isCore ? (
|
||||||
<Lock size={10} />
|
<Lock size={10} />
|
||||||
) : (
|
) : (
|
||||||
plugin.enabled && <Check size={10} />
|
plugin.enabled && <Check size={10} />
|
||||||
@@ -189,12 +185,12 @@ export function PluginListSetting({ pluginManager }: PluginListSettingProps) {
|
|||||||
</div>
|
</div>
|
||||||
<div className="plugin-info">
|
<div className="plugin-info">
|
||||||
<div className="plugin-header">
|
<div className="plugin-header">
|
||||||
<span className="plugin-name">{descriptor.name}</span>
|
<span className="plugin-name">{manifest.displayName}</span>
|
||||||
<span className="plugin-version">v{descriptor.version}</span>
|
<span className="plugin-version">v{manifest.version}</span>
|
||||||
</div>
|
</div>
|
||||||
{descriptor.description && (
|
{manifest.description && (
|
||||||
<div className="plugin-description">
|
<div className="plugin-description">
|
||||||
{descriptor.description}
|
{manifest.description}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="plugin-modules">
|
<div className="plugin-modules">
|
||||||
|
|||||||
@@ -928,11 +928,27 @@ function ContextMenuWithSubmenu({
|
|||||||
'other': { zh: '其他', en: 'Other' },
|
'other': { zh: '其他', en: 'Other' },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 实体创建模板的 label 本地化映射
|
||||||
|
const entityTemplateLabels: Record<string, { zh: string; en: string }> = {
|
||||||
|
'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 getCategoryLabel = (category: string) => {
|
||||||
const labels = categoryLabels[category];
|
const labels = categoryLabels[category];
|
||||||
return labels ? (locale === 'zh' ? labels.zh : labels.en) : 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 templatesByCategory = pluginTemplates.reduce((acc, template) => {
|
||||||
const cat = template.category || 'other';
|
const cat = template.category || 'other';
|
||||||
if (!acc[cat]) acc[cat] = [];
|
if (!acc[cat]) acc[cat] = [];
|
||||||
@@ -996,7 +1012,7 @@ function ContextMenuWithSubmenu({
|
|||||||
{templates.map((template) => (
|
{templates.map((template) => (
|
||||||
<button key={template.id} onClick={() => onCreateFromTemplate(template)}>
|
<button key={template.id} onClick={() => onCreateFromTemplate(template)}>
|
||||||
{getIconComponent(template.icon as string, 12)}
|
{getIconComponent(template.icon as string, 12)}
|
||||||
<span>{template.label}</span>
|
<span>{getEntityTemplateLabel(template.label)}</span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -13,8 +13,9 @@ import {
|
|||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Core } from '@esengine/ecs-framework';
|
import { Core } from '@esengine/ecs-framework';
|
||||||
import { SettingsService } from '../services/SettingsService';
|
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 { PluginListSetting } from './PluginListSetting';
|
||||||
|
import { ModuleListSetting } from './ModuleListSetting';
|
||||||
import '../styles/SettingsWindow.css';
|
import '../styles/SettingsWindow.css';
|
||||||
|
|
||||||
interface SettingsWindowProps {
|
interface SettingsWindowProps {
|
||||||
@@ -142,6 +143,9 @@ export function SettingsWindow({ onClose, settingsRegistry, initialCategoryId }:
|
|||||||
} else if (key === 'project.uiDesignResolution.preset') {
|
} else if (key === 'project.uiDesignResolution.preset') {
|
||||||
const resolution = projectService.getUIDesignResolution();
|
const resolution = projectService.getUIDesignResolution();
|
||||||
initialValues.set(key, `${resolution.width}x${resolution.height}`);
|
initialValues.set(key, `${resolution.width}x${resolution.height}`);
|
||||||
|
} else if (key === 'project.disabledModules') {
|
||||||
|
// Load disabled modules from ProjectService
|
||||||
|
initialValues.set(key, projectService.getDisabledModules());
|
||||||
} else {
|
} else {
|
||||||
initialValues.set(key, descriptor.defaultValue);
|
initialValues.set(key, descriptor.defaultValue);
|
||||||
}
|
}
|
||||||
@@ -199,6 +203,8 @@ export function SettingsWindow({ onClose, settingsRegistry, initialCategoryId }:
|
|||||||
let uiResolutionChanged = false;
|
let uiResolutionChanged = false;
|
||||||
let newWidth = 1920;
|
let newWidth = 1920;
|
||||||
let newHeight = 1080;
|
let newHeight = 1080;
|
||||||
|
let disabledModulesChanged = false;
|
||||||
|
let newDisabledModules: string[] = [];
|
||||||
|
|
||||||
for (const [key, value] of values.entries()) {
|
for (const [key, value] of values.entries()) {
|
||||||
if (key.startsWith('project.') && projectService) {
|
if (key.startsWith('project.') && projectService) {
|
||||||
@@ -215,6 +221,9 @@ export function SettingsWindow({ onClose, settingsRegistry, initialCategoryId }:
|
|||||||
newHeight = h;
|
newHeight = h;
|
||||||
uiResolutionChanged = true;
|
uiResolutionChanged = true;
|
||||||
}
|
}
|
||||||
|
} else if (key === 'project.disabledModules') {
|
||||||
|
newDisabledModules = value as string[];
|
||||||
|
disabledModulesChanged = true;
|
||||||
}
|
}
|
||||||
changedSettings[key] = value;
|
changedSettings[key] = value;
|
||||||
} else {
|
} else {
|
||||||
@@ -227,6 +236,10 @@ export function SettingsWindow({ onClose, settingsRegistry, initialCategoryId }:
|
|||||||
await projectService.setUIDesignResolution({ width: newWidth, height: newHeight });
|
await projectService.setUIDesignResolution({ width: newWidth, height: newHeight });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (disabledModulesChanged && projectService) {
|
||||||
|
await projectService.setDisabledModules(newDisabledModules);
|
||||||
|
}
|
||||||
|
|
||||||
console.log('[SettingsWindow] Saving settings, changedSettings:', changedSettings);
|
console.log('[SettingsWindow] Saving settings, changedSettings:', changedSettings);
|
||||||
window.dispatchEvent(new CustomEvent('settings:changed', {
|
window.dispatchEvent(new CustomEvent('settings:changed', {
|
||||||
detail: changedSettings
|
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 (
|
||||||
|
<div className="settings-module-list">
|
||||||
|
<ModuleListSetting
|
||||||
|
modules={moduleData.modules}
|
||||||
|
getModules={moduleData.getModules}
|
||||||
|
value={moduleValue}
|
||||||
|
onModulesChange={(newValue) => handleValueChange(setting.key, newValue, setting)}
|
||||||
|
useBlacklist={moduleData.useBlacklist}
|
||||||
|
validateDisable={moduleData.validateDisable}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ interface TitleBarProps {
|
|||||||
onOpenAbout?: () => void;
|
onOpenAbout?: () => void;
|
||||||
onCreatePlugin?: () => void;
|
onCreatePlugin?: () => void;
|
||||||
onReloadPlugins?: () => void;
|
onReloadPlugins?: () => void;
|
||||||
|
onOpenBuildSettings?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TitleBar({
|
export function TitleBar({
|
||||||
@@ -58,7 +59,8 @@ export function TitleBar({
|
|||||||
onToggleDevtools,
|
onToggleDevtools,
|
||||||
onOpenAbout,
|
onOpenAbout,
|
||||||
onCreatePlugin,
|
onCreatePlugin,
|
||||||
onReloadPlugins
|
onReloadPlugins,
|
||||||
|
onOpenBuildSettings
|
||||||
}: TitleBarProps) {
|
}: TitleBarProps) {
|
||||||
const [openMenu, setOpenMenu] = useState<string | null>(null);
|
const [openMenu, setOpenMenu] = useState<string | null>(null);
|
||||||
const [pluginMenuItems, setPluginMenuItems] = useState<PluginMenuItem[]>([]);
|
const [pluginMenuItems, setPluginMenuItems] = useState<PluginMenuItem[]>([]);
|
||||||
@@ -152,7 +154,8 @@ export function TitleBar({
|
|||||||
documentation: 'Documentation',
|
documentation: 'Documentation',
|
||||||
checkForUpdates: 'Check for Updates',
|
checkForUpdates: 'Check for Updates',
|
||||||
about: 'About',
|
about: 'About',
|
||||||
devtools: 'Developer Tools'
|
devtools: 'Developer Tools',
|
||||||
|
buildSettings: 'Build Settings'
|
||||||
},
|
},
|
||||||
zh: {
|
zh: {
|
||||||
file: '文件',
|
file: '文件',
|
||||||
@@ -187,7 +190,8 @@ export function TitleBar({
|
|||||||
documentation: '文档',
|
documentation: '文档',
|
||||||
checkForUpdates: '检查更新',
|
checkForUpdates: '检查更新',
|
||||||
about: '关于',
|
about: '关于',
|
||||||
devtools: '开发者工具'
|
devtools: '开发者工具',
|
||||||
|
buildSettings: '构建设置'
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
return translations[locale]?.[key] || key;
|
return translations[locale]?.[key] || key;
|
||||||
@@ -201,6 +205,8 @@ export function TitleBar({
|
|||||||
{ label: t('saveScene'), shortcut: 'Ctrl+S', onClick: onSaveScene },
|
{ label: t('saveScene'), shortcut: 'Ctrl+S', onClick: onSaveScene },
|
||||||
{ label: t('saveSceneAs'), shortcut: 'Ctrl+Shift+S', onClick: onSaveSceneAs },
|
{ label: t('saveSceneAs'), shortcut: 'Ctrl+Shift+S', onClick: onSaveSceneAs },
|
||||||
{ separator: true },
|
{ separator: true },
|
||||||
|
{ label: t('buildSettings'), shortcut: 'Ctrl+Shift+B', onClick: onOpenBuildSettings },
|
||||||
|
{ separator: true },
|
||||||
{ label: t('openProject'), onClick: onOpenProject },
|
{ label: t('openProject'), onClick: onOpenProject },
|
||||||
{ label: t('closeProject'), onClick: onCloseProject },
|
{ label: t('closeProject'), onClick: onCloseProject },
|
||||||
{ separator: true },
|
{ separator: true },
|
||||||
|
|||||||
@@ -17,69 +17,139 @@ import { open } from '@tauri-apps/plugin-shell';
|
|||||||
import { RuntimeResolver } from '../services/RuntimeResolver';
|
import { RuntimeResolver } from '../services/RuntimeResolver';
|
||||||
import { QRCodeDialog } from './QRCodeDialog';
|
import { QRCodeDialog } from './QRCodeDialog';
|
||||||
|
|
||||||
// Generate runtime HTML for browser preview
|
import type { ModuleManifest } from '../services/RuntimeResolver';
|
||||||
function generateRuntimeHtml(): string {
|
|
||||||
|
/**
|
||||||
|
* 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<string, string>, modules: ModuleManifest[]): string {
|
||||||
|
const importMapScript = `<script type="importmap">
|
||||||
|
${JSON.stringify({ imports: importMap }, null, 2).split('\n').join('\n ')}
|
||||||
|
</script>`;
|
||||||
|
|
||||||
|
// 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 `<!DOCTYPE html>
|
return `<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
|
||||||
<title>ECS Runtime Preview</title>
|
<title>ECS Runtime Preview</title>
|
||||||
|
${importMapScript}
|
||||||
<style>
|
<style>
|
||||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
html, body {
|
html, body { width: 100%; height: 100%; overflow: hidden; background: #1a1a2e; }
|
||||||
background: #1e1e1e;
|
#game-canvas { width: 100%; height: 100%; display: block; }
|
||||||
margin: 0;
|
#loading {
|
||||||
padding: 0;
|
position: fixed; top: 0; left: 0; right: 0; bottom: 0;
|
||||||
overflow: hidden;
|
display: flex; flex-direction: column;
|
||||||
width: 100%;
|
align-items: center; justify-content: center;
|
||||||
height: 100%;
|
background: #1a1a2e; color: #eee; font-family: sans-serif;
|
||||||
position: fixed;
|
|
||||||
}
|
}
|
||||||
canvas {
|
#loading .spinner {
|
||||||
display: block;
|
width: 40px; height: 40px; border: 3px solid #333;
|
||||||
touch-action: none;
|
border-top-color: #4a9eff; border-radius: 50%;
|
||||||
user-select: none;
|
animation: spin 1s linear infinite;
|
||||||
-webkit-user-select: none;
|
}
|
||||||
-webkit-user-drag: none;
|
#loading .message { margin-top: 16px; font-size: 14px; }
|
||||||
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
|
#error {
|
||||||
|
position: fixed; top: 0; left: 0; right: 0; bottom: 0;
|
||||||
|
display: none; flex-direction: column;
|
||||||
|
align-items: center; justify-content: center;
|
||||||
|
background: #1a1a2e; color: #ff6b6b; font-family: sans-serif;
|
||||||
|
padding: 20px; text-align: center;
|
||||||
|
}
|
||||||
|
#error.show { display: flex; }
|
||||||
|
#error h2 { margin-bottom: 16px; }
|
||||||
|
#error pre {
|
||||||
|
background: rgba(0,0,0,0.3); padding: 16px; border-radius: 8px;
|
||||||
|
max-width: 600px; white-space: pre-wrap; word-break: break-word;
|
||||||
|
font-size: 13px; line-height: 1.5;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<canvas id="runtime-canvas"></canvas>
|
<div id="loading">
|
||||||
<script src="/runtime.browser.js"></script>
|
<div class="spinner"></div>
|
||||||
|
<div class="message" id="loading-message">Loading...</div>
|
||||||
|
</div>
|
||||||
|
<div id="error">
|
||||||
|
<h2 id="error-title">Failed to start</h2>
|
||||||
|
<pre id="error-message"></pre>
|
||||||
|
</div>
|
||||||
|
<canvas id="game-canvas"></canvas>
|
||||||
|
|
||||||
<script type="module">
|
<script type="module">
|
||||||
import * as esEngine from '/es_engine.js';
|
const loading = document.getElementById('loading');
|
||||||
(async function() {
|
const loadingMessage = document.getElementById('loading-message');
|
||||||
try {
|
const errorDiv = document.getElementById('error');
|
||||||
// Set canvas size before creating runtime
|
const errorTitle = document.getElementById('error-title');
|
||||||
const canvas = document.getElementById('runtime-canvas');
|
const errorMessage = document.getElementById('error-message');
|
||||||
canvas.width = window.innerWidth;
|
|
||||||
canvas.height = window.innerHeight;
|
|
||||||
|
|
||||||
const runtime = ECSRuntime.create({
|
function showError(title, msg) {
|
||||||
canvasId: 'runtime-canvas',
|
loading.style.display = 'none';
|
||||||
width: window.innerWidth,
|
errorTitle.textContent = title || 'Failed to start';
|
||||||
height: window.innerHeight,
|
errorMessage.textContent = msg;
|
||||||
projectConfigUrl: '/ecs-editor.config.json'
|
errorDiv.classList.add('show');
|
||||||
});
|
console.error('[Preview]', msg);
|
||||||
|
}
|
||||||
|
|
||||||
await runtime.initialize(esEngine);
|
function updateLoading(msg) {
|
||||||
await runtime.loadScene('/scene.json?_=' + Date.now());
|
loadingMessage.textContent = msg;
|
||||||
runtime.start();
|
console.log('[Preview]', msg);
|
||||||
|
}
|
||||||
|
|
||||||
window.addEventListener('resize', () => {
|
try {
|
||||||
const canvas = document.getElementById('runtime-canvas');
|
updateLoading('Loading runtime...');
|
||||||
const newWidth = window.innerWidth;
|
const ECSRuntime = (await import('@esengine/platform-web')).default;
|
||||||
const newHeight = window.innerHeight;
|
|
||||||
canvas.width = newWidth;
|
updateLoading('Loading WASM module...');
|
||||||
canvas.height = newHeight;
|
const wasmModule = await import('./libs/es-engine/es_engine.js');
|
||||||
runtime.handleResize(newWidth, newHeight);
|
|
||||||
});
|
updateLoading('Initializing runtime...');
|
||||||
} catch (e) {
|
const runtime = ECSRuntime.create({
|
||||||
console.error('Runtime error:', e);
|
canvasId: 'game-canvas',
|
||||||
}
|
width: window.innerWidth,
|
||||||
})();
|
height: window.innerHeight,
|
||||||
|
assetBaseUrl: './assets',
|
||||||
|
projectConfigUrl: './ecs-editor.config.json'
|
||||||
|
});
|
||||||
|
|
||||||
|
updateLoading('Loading plugins...');
|
||||||
|
${pluginImportCode}
|
||||||
|
|
||||||
|
await runtime.initialize(wasmModule);
|
||||||
|
|
||||||
|
updateLoading('Loading scene...');
|
||||||
|
await runtime.loadScene('./scene.json?_=' + Date.now());
|
||||||
|
|
||||||
|
loading.style.display = 'none';
|
||||||
|
runtime.start();
|
||||||
|
|
||||||
|
window.addEventListener('resize', () => {
|
||||||
|
runtime.handleResize(window.innerWidth, window.innerHeight);
|
||||||
|
});
|
||||||
|
console.log('[Preview] Started successfully');
|
||||||
|
} catch (error) {
|
||||||
|
showError(null, error.message || String(error));
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>`;
|
</html>`;
|
||||||
@@ -697,13 +767,13 @@ export function Viewport({ locale = 'en', messageHub }: ViewportProps) {
|
|||||||
await TauriAPI.createDirectory(runtimeDir);
|
await TauriAPI.createDirectory(runtimeDir);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use RuntimeResolver to copy runtime files
|
// Use RuntimeResolver to copy runtime files with ES Modules structure
|
||||||
// 使用 RuntimeResolver 复制运行时文件
|
// 使用 RuntimeResolver 复制运行时文件(ES 模块结构)
|
||||||
const runtimeResolver = RuntimeResolver.getInstance();
|
const runtimeResolver = RuntimeResolver.getInstance();
|
||||||
await runtimeResolver.initialize();
|
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);
|
await TauriAPI.writeFileContent(`${runtimeDir}/scene.json`, sceneData);
|
||||||
|
|
||||||
// Copy project config file (for plugin settings)
|
// 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));
|
await TauriAPI.writeFileContent(`${runtimeDir}/asset-catalog.json`, JSON.stringify(assetCatalog, null, 2));
|
||||||
console.log(`[Viewport] Asset catalog created with ${Object.keys(catalogEntries).length} entries`);
|
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);
|
await TauriAPI.writeFileContent(`${runtimeDir}/index.html`, runtimeHtml);
|
||||||
|
|
||||||
// Start local server and open browser
|
// Start local server and open browser
|
||||||
@@ -865,10 +936,10 @@ export function Viewport({ locale = 'en', messageHub }: ViewportProps) {
|
|||||||
await TauriAPI.createDirectory(runtimeDir);
|
await TauriAPI.createDirectory(runtimeDir);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use RuntimeResolver to copy runtime files
|
// Use RuntimeResolver to copy runtime files with ES Modules structure
|
||||||
const runtimeResolver = RuntimeResolver.getInstance();
|
const runtimeResolver = RuntimeResolver.getInstance();
|
||||||
await runtimeResolver.initialize();
|
await runtimeResolver.initialize();
|
||||||
await runtimeResolver.prepareRuntimeFiles(runtimeDir);
|
const { modules, importMap } = await runtimeResolver.prepareRuntimeFiles(runtimeDir);
|
||||||
|
|
||||||
// Copy project config file (for plugin settings)
|
// Copy project config file (for plugin settings)
|
||||||
const projectService = Core.services.tryResolve(ProjectService);
|
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);
|
const sceneDataStr = typeof sceneData === 'string' ? sceneData : new TextDecoder().decode(sceneData);
|
||||||
await TauriAPI.writeFileContent(`${runtimeDir}/scene.json`, sceneDataStr);
|
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
|
// Copy textures referenced in scene
|
||||||
const assetsDir = `${runtimeDir}\\assets`;
|
const assetsDir = `${runtimeDir}\\assets`;
|
||||||
|
|||||||
@@ -99,7 +99,11 @@ export function Inspector({ entityStore: _entityStore, messageHub, inspectorRegi
|
|||||||
'conf',
|
'conf',
|
||||||
'log',
|
'log',
|
||||||
'btree',
|
'btree',
|
||||||
'ecs'
|
'ecs',
|
||||||
|
'mat',
|
||||||
|
'shader',
|
||||||
|
'tilemap',
|
||||||
|
'tileset'
|
||||||
];
|
];
|
||||||
const imageExtensions = ['png', 'jpg', 'jpeg', 'gif', 'bmp', 'webp', 'svg', 'ico', 'tiff', 'tif'];
|
const imageExtensions = ['png', 'jpg', 'jpeg', 'gif', 'bmp', 'webp', 'svg', 'ico', 'tiff', 'tif'];
|
||||||
const isTextFile = fileInfo.extension && textExtensions.includes(fileInfo.extension.toLowerCase());
|
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') {
|
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 <AssetFileInspector fileInfo={target.data} content={target.content} isImage={target.isImage} />;
|
return <AssetFileInspector fileInfo={target.data} content={target.content} isImage={target.isImage} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -59,16 +59,26 @@ export function EntityInspector({ entity, messageHub, commandManager, componentV
|
|||||||
const availableComponents = (componentRegistry?.getAllComponents() || []) as ComponentInfo[];
|
const availableComponents = (componentRegistry?.getAllComponents() || []) as ComponentInfo[];
|
||||||
|
|
||||||
// 当 entity 变化或组件数量变化时,更新展开状态(新组件默认展开)
|
// 当 entity 变化或组件数量变化时,更新展开状态(新组件默认展开)
|
||||||
|
// 注意:不要依赖 componentVersion,否则每次属性变化都会重置展开状态
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setExpandedComponents((prev) => {
|
setExpandedComponents((prev) => {
|
||||||
const newSet = new Set(prev);
|
const newSet = new Set(prev);
|
||||||
// 添加所有当前组件的索引(保留已有的展开状态)
|
// 只添加新增组件的索引(保留已有的展开/收缩状态)
|
||||||
entity.components.forEach((_, index) => {
|
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;
|
return newSet;
|
||||||
});
|
});
|
||||||
}, [entity, entity.components.length, componentVersion]);
|
}, [entity, entity.components.length]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (showComponentMenu && addButtonRef.current) {
|
if (showComponentMenu && addButtonRef.current) {
|
||||||
@@ -439,6 +449,15 @@ export function EntityInspector({ entity, messageHub, commandManager, componentV
|
|||||||
onAction={handlePropertyAction}
|
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 */}
|
{/* Dynamic component actions from plugins */}
|
||||||
{componentActionRegistry?.getActionsForComponent(componentName).map((action) => {
|
{componentActionRegistry?.getActionsForComponent(componentName).map((action) => {
|
||||||
// 解析图标:支持字符串(Lucide 图标名)或 React 元素
|
// 解析图标:支持字符串(Lucide 图标名)或 React 元素
|
||||||
|
|||||||
187
packages/editor-app/src/components/styles/ModuleListSetting.css
Normal file
187
packages/editor-app/src/components/styles/ModuleListSetting.css
Normal file
@@ -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);
|
||||||
|
}
|
||||||
@@ -50,7 +50,12 @@
|
|||||||
"core": "Core",
|
"core": "Core",
|
||||||
"rendering": "Rendering",
|
"rendering": "Rendering",
|
||||||
"physics": "Physics",
|
"physics": "Physics",
|
||||||
"audio": "Audio"
|
"audio": "Audio",
|
||||||
|
"tilemap": "Tilemap"
|
||||||
|
},
|
||||||
|
"material": {
|
||||||
|
"name": "Material",
|
||||||
|
"description": "Custom material and shader component"
|
||||||
},
|
},
|
||||||
"transform": {
|
"transform": {
|
||||||
"description": "Transform - Position, Rotation, Scale"
|
"description": "Transform - Position, Rotation, Scale"
|
||||||
@@ -76,5 +81,16 @@
|
|||||||
"audioSource": {
|
"audioSource": {
|
||||||
"description": "Audio Source"
|
"description": "Audio Source"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"file": {
|
||||||
|
"create": {
|
||||||
|
"material": "Material",
|
||||||
|
"shader": "Shader"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"entity": {
|
||||||
|
"create": {
|
||||||
|
"materialEntity": "Material Entity"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,7 +50,12 @@
|
|||||||
"core": "基础",
|
"core": "基础",
|
||||||
"rendering": "渲染",
|
"rendering": "渲染",
|
||||||
"physics": "物理",
|
"physics": "物理",
|
||||||
"audio": "音频"
|
"audio": "音频",
|
||||||
|
"tilemap": "瓦片地图"
|
||||||
|
},
|
||||||
|
"material": {
|
||||||
|
"name": "材质",
|
||||||
|
"description": "自定义材质和着色器组件"
|
||||||
},
|
},
|
||||||
"transform": {
|
"transform": {
|
||||||
"description": "变换组件 - 位置、旋转、缩放"
|
"description": "变换组件 - 位置、旋转、缩放"
|
||||||
@@ -76,5 +81,16 @@
|
|||||||
"audioSource": {
|
"audioSource": {
|
||||||
"description": "音频源组件"
|
"description": "音频源组件"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"file": {
|
||||||
|
"create": {
|
||||||
|
"material": "材质",
|
||||||
|
"shader": "着色器"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"entity": {
|
||||||
|
"create": {
|
||||||
|
"materialEntity": "材质实体"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
import type { ServiceContainer } from '@esengine/ecs-framework';
|
import type { ServiceContainer } from '@esengine/ecs-framework';
|
||||||
import { createLogger } 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 { SettingsRegistry } from '@esengine/editor-core';
|
||||||
import { SettingsService } from '../../services/SettingsService';
|
import { SettingsService } from '../../services/SettingsService';
|
||||||
|
|
||||||
@@ -102,27 +102,23 @@ class EditorAppearanceEditorModule implements IEditorModuleLoader {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const descriptor: PluginDescriptor = {
|
const manifest: ModuleManifest = {
|
||||||
id: '@esengine/editor-appearance',
|
id: '@esengine/editor-appearance',
|
||||||
name: 'Editor Appearance',
|
name: '@esengine/editor-appearance',
|
||||||
|
displayName: 'Editor Appearance',
|
||||||
version: '1.0.0',
|
version: '1.0.0',
|
||||||
description: 'Configure editor appearance settings',
|
description: 'Configure editor appearance settings',
|
||||||
category: 'tools',
|
category: 'Other',
|
||||||
icon: 'Palette',
|
icon: 'Palette',
|
||||||
enabledByDefault: true,
|
|
||||||
canContainContent: false,
|
|
||||||
isEnginePlugin: true,
|
|
||||||
isCore: true,
|
isCore: true,
|
||||||
modules: [
|
defaultEnabled: true,
|
||||||
{
|
isEngineModule: true,
|
||||||
name: 'EditorAppearanceEditor',
|
canContainContent: false,
|
||||||
type: 'editor',
|
dependencies: [],
|
||||||
loadingPhase: 'earliest'
|
exports: {}
|
||||||
}
|
|
||||||
]
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const EditorAppearancePlugin: IPlugin = {
|
export const EditorAppearancePlugin: IPlugin = {
|
||||||
descriptor,
|
manifest,
|
||||||
editorModule: new EditorAppearanceEditorModule()
|
editorModule: new EditorAppearanceEditorModule()
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { ServiceContainer } from '@esengine/ecs-framework';
|
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';
|
import { registerSpriteGizmo } from '../../gizmos';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -24,27 +24,25 @@ class GizmoEditorModule implements IEditorModuleLoader {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const descriptor: PluginDescriptor = {
|
const manifest: ModuleManifest = {
|
||||||
id: '@esengine/gizmo',
|
id: '@esengine/gizmo',
|
||||||
name: 'Gizmo System',
|
name: '@esengine/gizmo',
|
||||||
|
displayName: 'Gizmo System',
|
||||||
version: '1.0.0',
|
version: '1.0.0',
|
||||||
description: 'Provides gizmo support for editor components',
|
description: 'Provides gizmo support for editor components',
|
||||||
category: 'tools',
|
category: 'Other',
|
||||||
icon: 'Move',
|
icon: 'Move',
|
||||||
enabledByDefault: true,
|
|
||||||
canContainContent: false,
|
|
||||||
isEnginePlugin: true,
|
|
||||||
isCore: true,
|
isCore: true,
|
||||||
modules: [
|
defaultEnabled: true,
|
||||||
{
|
isEngineModule: true,
|
||||||
name: 'GizmoEditor',
|
canContainContent: false,
|
||||||
type: 'editor',
|
dependencies: ['engine-core'],
|
||||||
loadingPhase: 'preDefault'
|
exports: {
|
||||||
}
|
other: ['GizmoRegistry']
|
||||||
]
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const GizmoPlugin: IPlugin = {
|
export const GizmoPlugin: IPlugin = {
|
||||||
descriptor,
|
manifest,
|
||||||
editorModule: new GizmoEditorModule()
|
editorModule: new GizmoEditorModule()
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
import type { ServiceContainer } from '@esengine/ecs-framework';
|
import type { ServiceContainer } from '@esengine/ecs-framework';
|
||||||
import { createLogger } 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 { SettingsRegistry } from '@esengine/editor-core';
|
||||||
|
|
||||||
const logger = createLogger('PluginConfigPlugin');
|
const logger = createLogger('PluginConfigPlugin');
|
||||||
@@ -51,27 +51,23 @@ class PluginConfigEditorModule implements IEditorModuleLoader {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const descriptor: PluginDescriptor = {
|
const manifest: ModuleManifest = {
|
||||||
id: '@esengine/plugin-config',
|
id: '@esengine/plugin-config',
|
||||||
name: 'Plugin Config',
|
name: '@esengine/plugin-config',
|
||||||
|
displayName: 'Plugin Config',
|
||||||
version: '1.0.0',
|
version: '1.0.0',
|
||||||
description: 'Configure engine plugins',
|
description: 'Configure engine plugins',
|
||||||
category: 'tools',
|
category: 'Other',
|
||||||
icon: 'Package',
|
icon: 'Package',
|
||||||
enabledByDefault: true,
|
|
||||||
canContainContent: false,
|
|
||||||
isEnginePlugin: true,
|
|
||||||
isCore: true,
|
isCore: true,
|
||||||
modules: [
|
defaultEnabled: true,
|
||||||
{
|
isEngineModule: true,
|
||||||
name: 'PluginConfigEditor',
|
canContainContent: false,
|
||||||
type: 'editor',
|
dependencies: [],
|
||||||
loadingPhase: 'postDefault'
|
exports: {}
|
||||||
}
|
|
||||||
]
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const PluginConfigPlugin: IPlugin = {
|
export const PluginConfigPlugin: IPlugin = {
|
||||||
descriptor,
|
manifest,
|
||||||
editorModule: new PluginConfigEditorModule()
|
editorModule: new PluginConfigEditorModule()
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import type { ServiceContainer } from '@esengine/ecs-framework';
|
|||||||
import type {
|
import type {
|
||||||
IPlugin,
|
IPlugin,
|
||||||
IEditorModuleLoader,
|
IEditorModuleLoader,
|
||||||
PluginDescriptor,
|
ModuleManifest,
|
||||||
MenuItemDescriptor
|
MenuItemDescriptor
|
||||||
} from '@esengine/editor-core';
|
} from '@esengine/editor-core';
|
||||||
import { MessageHub, SettingsRegistry } from '@esengine/editor-core';
|
import { MessageHub, SettingsRegistry } from '@esengine/editor-core';
|
||||||
@@ -114,26 +114,23 @@ class ProfilerEditorModule implements IEditorModuleLoader {
|
|||||||
async onEditorReady(): Promise<void> {}
|
async onEditorReady(): Promise<void> {}
|
||||||
}
|
}
|
||||||
|
|
||||||
const descriptor: PluginDescriptor = {
|
const manifest: ModuleManifest = {
|
||||||
id: '@esengine/profiler',
|
id: '@esengine/profiler',
|
||||||
name: 'Performance Profiler',
|
name: '@esengine/profiler',
|
||||||
|
displayName: 'Performance Profiler',
|
||||||
version: '1.0.0',
|
version: '1.0.0',
|
||||||
description: 'Real-time performance monitoring for ECS systems',
|
description: 'Real-time performance monitoring for ECS systems',
|
||||||
category: 'tools',
|
category: 'Other',
|
||||||
icon: 'BarChart3',
|
icon: 'BarChart3',
|
||||||
enabledByDefault: true,
|
isCore: false,
|
||||||
|
defaultEnabled: true,
|
||||||
|
isEngineModule: true,
|
||||||
canContainContent: false,
|
canContainContent: false,
|
||||||
isEnginePlugin: true,
|
dependencies: [],
|
||||||
modules: [
|
exports: {}
|
||||||
{
|
|
||||||
name: 'ProfilerEditor',
|
|
||||||
type: 'editor',
|
|
||||||
loadingPhase: 'postDefault'
|
|
||||||
}
|
|
||||||
]
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ProfilerPlugin: IPlugin = {
|
export const ProfilerPlugin: IPlugin = {
|
||||||
descriptor,
|
manifest,
|
||||||
editorModule: new ProfilerEditorModule()
|
editorModule: new ProfilerEditorModule()
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -8,10 +8,25 @@
|
|||||||
|
|
||||||
import type { ServiceContainer } from '@esengine/ecs-framework';
|
import type { ServiceContainer } from '@esengine/ecs-framework';
|
||||||
import { createLogger, Core } from '@esengine/ecs-framework';
|
import { createLogger, Core } from '@esengine/ecs-framework';
|
||||||
import type { IPlugin, IEditorModuleLoader, PluginDescriptor } from '@esengine/editor-core';
|
import type { IPlugin, IEditorModuleLoader, ModuleManifest } from '@esengine/editor-core';
|
||||||
import { SettingsRegistry, ProjectService } from '@esengine/editor-core';
|
import { SettingsRegistry, ProjectService, moduleRegistry } from '@esengine/editor-core';
|
||||||
import EngineService from '../../services/EngineService';
|
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');
|
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',
|
id: '@esengine/project-settings',
|
||||||
name: 'Project Settings',
|
name: '@esengine/project-settings',
|
||||||
|
displayName: 'Project Settings',
|
||||||
version: '1.0.0',
|
version: '1.0.0',
|
||||||
description: 'Configure project-level settings',
|
description: 'Configure project-level settings',
|
||||||
category: 'tools',
|
category: 'Other',
|
||||||
icon: 'Settings',
|
icon: 'Settings',
|
||||||
enabledByDefault: true,
|
|
||||||
canContainContent: false,
|
|
||||||
isEnginePlugin: true,
|
|
||||||
isCore: true,
|
isCore: true,
|
||||||
modules: [
|
defaultEnabled: true,
|
||||||
{
|
isEngineModule: true,
|
||||||
name: 'ProjectSettingsEditor',
|
canContainContent: false,
|
||||||
type: 'editor',
|
dependencies: [],
|
||||||
loadingPhase: 'postDefault'
|
exports: {}
|
||||||
}
|
|
||||||
]
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ProjectSettingsPlugin: IPlugin = {
|
export const ProjectSettingsPlugin: IPlugin = {
|
||||||
descriptor,
|
manifest,
|
||||||
editorModule: new ProjectSettingsEditorModule()
|
editorModule: new ProjectSettingsEditorModule()
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import type { ServiceContainer } from '@esengine/ecs-framework';
|
|||||||
import type {
|
import type {
|
||||||
IPlugin,
|
IPlugin,
|
||||||
IEditorModuleLoader,
|
IEditorModuleLoader,
|
||||||
PluginDescriptor,
|
ModuleManifest,
|
||||||
PanelDescriptor,
|
PanelDescriptor,
|
||||||
MenuItemDescriptor,
|
MenuItemDescriptor,
|
||||||
ToolbarItemDescriptor,
|
ToolbarItemDescriptor,
|
||||||
@@ -173,27 +173,25 @@ class SceneInspectorEditorModule implements IEditorModuleLoader {
|
|||||||
async onProjectClose(): Promise<void> {}
|
async onProjectClose(): Promise<void> {}
|
||||||
}
|
}
|
||||||
|
|
||||||
const descriptor: PluginDescriptor = {
|
const manifest: ModuleManifest = {
|
||||||
id: '@esengine/scene-inspector',
|
id: '@esengine/scene-inspector',
|
||||||
name: 'Scene Inspector',
|
name: '@esengine/scene-inspector',
|
||||||
|
displayName: 'Scene Inspector',
|
||||||
version: '1.0.0',
|
version: '1.0.0',
|
||||||
description: 'Scene hierarchy and entity inspector',
|
description: 'Scene hierarchy and entity inspector',
|
||||||
category: 'tools',
|
category: 'Other',
|
||||||
icon: 'Search',
|
icon: 'Search',
|
||||||
enabledByDefault: true,
|
|
||||||
canContainContent: false,
|
|
||||||
isEnginePlugin: true,
|
|
||||||
isCore: true,
|
isCore: true,
|
||||||
modules: [
|
defaultEnabled: true,
|
||||||
{
|
isEngineModule: true,
|
||||||
name: 'SceneInspectorEditor',
|
canContainContent: false,
|
||||||
type: 'editor',
|
dependencies: ['engine-core'],
|
||||||
loadingPhase: 'default'
|
exports: {
|
||||||
}
|
other: ['SceneHierarchy', 'EntityInspector']
|
||||||
]
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const SceneInspectorPlugin: IPlugin = {
|
export const SceneInspectorPlugin: IPlugin = {
|
||||||
descriptor,
|
manifest,
|
||||||
editorModule: new SceneInspectorEditorModule()
|
editorModule: new SceneInspectorEditorModule()
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ export { GizmoPlugin } from './GizmoPlugin';
|
|||||||
export { SceneInspectorPlugin } from './SceneInspectorPlugin';
|
export { SceneInspectorPlugin } from './SceneInspectorPlugin';
|
||||||
export { ProfilerPlugin } from './ProfilerPlugin';
|
export { ProfilerPlugin } from './ProfilerPlugin';
|
||||||
export { EditorAppearancePlugin } from './EditorAppearancePlugin';
|
export { EditorAppearancePlugin } from './EditorAppearancePlugin';
|
||||||
export { PluginConfigPlugin } from './PluginConfigPlugin';
|
|
||||||
export { ProjectSettingsPlugin } from './ProjectSettingsPlugin';
|
export { ProjectSettingsPlugin } from './ProjectSettingsPlugin';
|
||||||
|
// Note: PluginConfigPlugin removed - module management is now unified in ProjectSettingsPlugin
|
||||||
// TODO: Re-enable when blueprint-editor package is fixed
|
// TODO: Re-enable when blueprint-editor package is fixed
|
||||||
// export { BlueprintPlugin } from '@esengine/blueprint-editor';
|
// export { BlueprintPlugin } from '@esengine/blueprint-editor';
|
||||||
|
|||||||
243
packages/editor-app/src/services/BuildFileSystemService.ts
Normal file
243
packages/editor-app/src/services/BuildFileSystemService.ts
Normal file
@@ -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<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<void> {
|
||||||
|
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<number> {
|
||||||
|
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<BundleResult> {
|
||||||
|
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<void> {
|
||||||
|
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<number> {
|
||||||
|
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<number> {
|
||||||
|
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<void> {
|
||||||
|
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<string[]> {
|
||||||
|
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<void> {
|
||||||
|
await invoke('copy_file', { src, dst });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if path exists.
|
||||||
|
* 检查路径是否存在。
|
||||||
|
*
|
||||||
|
* @param path - Path to check | 要检查的路径
|
||||||
|
* @returns Whether path exists | 路径是否存在
|
||||||
|
*/
|
||||||
|
async pathExists(path: string): Promise<boolean> {
|
||||||
|
return await invoke('path_exists', { path });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read file content.
|
||||||
|
* 读取文件内容。
|
||||||
|
*
|
||||||
|
* @param path - File path | 文件路径
|
||||||
|
* @returns File content | 文件内容
|
||||||
|
*/
|
||||||
|
async readFile(path: string): Promise<string> {
|
||||||
|
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<void> {
|
||||||
|
await invoke('write_file_content', { path, content });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read JSON file.
|
||||||
|
* 读取 JSON 文件。
|
||||||
|
*
|
||||||
|
* @param path - File path | 文件路径
|
||||||
|
* @returns Parsed JSON object | 解析后的 JSON 对象
|
||||||
|
*/
|
||||||
|
async readJson<T>(path: string): Promise<T> {
|
||||||
|
const content = await invoke<string>('read_file_content', { path });
|
||||||
|
return JSON.parse(content) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create directory.
|
||||||
|
* 创建目录。
|
||||||
|
*
|
||||||
|
* @param path - Directory path | 目录路径
|
||||||
|
*/
|
||||||
|
async createDirectory(path: string): Promise<void> {
|
||||||
|
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<string> {
|
||||||
|
return await invoke('read_binary_file_as_base64', { path });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Singleton instance | 单例实例
|
||||||
|
export const buildFileSystem = new BuildFileSystemService();
|
||||||
@@ -27,8 +27,10 @@ import {
|
|||||||
EditorPlatformAdapter,
|
EditorPlatformAdapter,
|
||||||
type GameRuntimeConfig
|
type GameRuntimeConfig
|
||||||
} from '@esengine/runtime-core';
|
} from '@esengine/runtime-core';
|
||||||
|
import { getMaterialManager } from '@esengine/material-system';
|
||||||
import { convertFileSrc } from '@tauri-apps/api/core';
|
import { convertFileSrc } from '@tauri-apps/api/core';
|
||||||
import { IdGenerator } from '../utils/idGenerator';
|
import { IdGenerator } from '../utils/idGenerator';
|
||||||
|
import { TauriAssetReader } from './TauriAssetReader';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Engine service singleton for editor integration.
|
* Engine service singleton for editor integration.
|
||||||
@@ -191,7 +193,7 @@ export class EngineService {
|
|||||||
|
|
||||||
// 创建系统上下文
|
// 创建系统上下文
|
||||||
const context: SystemContext = {
|
const context: SystemContext = {
|
||||||
core: Core,
|
services: Core.services,
|
||||||
engineBridge: this._runtime.bridge,
|
engineBridge: this._runtime.bridge,
|
||||||
renderSystem: this._runtime.renderSystem,
|
renderSystem: this._runtime.renderSystem,
|
||||||
assetManager: this._assetManager,
|
assetManager: this._assetManager,
|
||||||
@@ -345,11 +347,25 @@ export class EngineService {
|
|||||||
try {
|
try {
|
||||||
this._assetManager = new AssetManager();
|
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>(ProjectService);
|
||||||
|
if (projectService && projectService.isProjectOpen()) {
|
||||||
|
const projectInfo = projectService.getCurrentProject();
|
||||||
|
if (projectInfo) {
|
||||||
|
this._assetManager.setProjectRoot(projectInfo.path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const pathTransformerFn = (path: string) => {
|
const pathTransformerFn = (path: string) => {
|
||||||
if (!path.startsWith('http://') && !path.startsWith('https://') &&
|
if (!path.startsWith('http://') && !path.startsWith('https://') &&
|
||||||
!path.startsWith('data:') && !path.startsWith('asset://')) {
|
!path.startsWith('data:') && !path.startsWith('asset://')) {
|
||||||
if (!path.startsWith('/') && !path.match(/^[a-zA-Z]:/)) {
|
if (!path.startsWith('/') && !path.match(/^[a-zA-Z]:/)) {
|
||||||
const projectService = Core.services.tryResolve<ProjectService>(ProjectService);
|
|
||||||
if (projectService && projectService.isProjectOpen()) {
|
if (projectService && projectService.isProjectOpen()) {
|
||||||
const projectInfo = projectService.getCurrentProject();
|
const projectInfo = projectService.getCurrentProject();
|
||||||
if (projectInfo) {
|
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._assetSystemInitialized = true;
|
||||||
this._initializationError = null;
|
this._initializationError = null;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { PluginManager, LocaleService, MessageHub } from '@esengine/editor-core';
|
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 { Core } from '@esengine/ecs-framework';
|
||||||
import { TauriAPI } from '../api/tauri';
|
import { TauriAPI } from '../api/tauri';
|
||||||
import { PluginSDKRegistry } from './PluginSDKRegistry';
|
import { PluginSDKRegistry } from './PluginSDKRegistry';
|
||||||
@@ -132,7 +132,7 @@ export class PluginLoader {
|
|||||||
pluginManager.register(pluginLoader);
|
pluginManager.register(pluginLoader);
|
||||||
|
|
||||||
// 8. 初始化编辑器模块(注册面板、文件处理器等)
|
// 8. 初始化编辑器模块(注册面板、文件处理器等)
|
||||||
const pluginId = pluginLoader.descriptor.id;
|
const pluginId = pluginLoader.manifest.id;
|
||||||
await pluginManager.initializePluginEditor(pluginId, Core.services);
|
await pluginManager.initializePluginEditor(pluginId, Core.services);
|
||||||
|
|
||||||
// 9. 记录已加载
|
// 9. 记录已加载
|
||||||
@@ -285,7 +285,7 @@ export class PluginLoader {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 新的 IPluginLoader 接口检查
|
// 新的 IPluginLoader 接口检查
|
||||||
if (obj.descriptor && this.isPluginDescriptor(obj.descriptor)) {
|
if (obj.manifest && this.isModuleManifest(obj.manifest)) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -293,9 +293,9 @@ export class PluginLoader {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 验证对象是否为有效的插件描述符
|
* 验证对象是否为有效的模块清单
|
||||||
*/
|
*/
|
||||||
private isPluginDescriptor(obj: any): obj is PluginDescriptor {
|
private isModuleManifest(obj: any): obj is ModuleManifest {
|
||||||
return (
|
return (
|
||||||
obj &&
|
obj &&
|
||||||
typeof obj.id === 'string' &&
|
typeof obj.id === 'string' &&
|
||||||
|
|||||||
@@ -1,56 +1,32 @@
|
|||||||
/**
|
/**
|
||||||
* Runtime Module Resolver
|
|
||||||
* 运行时模块解析器
|
* 运行时模块解析器
|
||||||
*
|
* Runtime Module Resolver
|
||||||
* Resolves runtime module paths based on environment and configuration
|
|
||||||
* 根据环境和配置解析运行时模块路径
|
|
||||||
*
|
|
||||||
* 运行时文件打包在编辑器内,离线可用
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { TauriAPI } from '../api/tauri';
|
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
|
* Module manifest for runtime modules
|
||||||
const segments = path.split(/[/\\]/).filter((segment) =>
|
*/
|
||||||
segment !== '..' && segment !== '.' && segment !== ''
|
export interface ModuleManifest {
|
||||||
);
|
id: string;
|
||||||
// Use Windows backslash for consistency
|
name: string;
|
||||||
return segments.join('\\');
|
version: string;
|
||||||
};
|
dependencies: string[];
|
||||||
|
hasRuntime: boolean;
|
||||||
// Check if we're in development mode
|
pluginExport?: string;
|
||||||
const isDevelopment = (): boolean => {
|
requiresWasm?: boolean;
|
||||||
try {
|
wasmPaths?: string[];
|
||||||
// Vite environment variable - this is the most reliable check
|
runtimeWasmPath?: string;
|
||||||
const viteDev = (import.meta as any).env?.DEV === true;
|
externalDependencies?: string[];
|
||||||
// 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<string, any>;
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export class RuntimeResolver {
|
export class RuntimeResolver {
|
||||||
private static instance: RuntimeResolver;
|
private static instance: RuntimeResolver;
|
||||||
private config: RuntimeConfig | null = null;
|
|
||||||
private baseDir: string = '';
|
private baseDir: string = '';
|
||||||
private isDev: boolean = false; // Store dev mode state at initialization time
|
private engineModulesPath: string = '';
|
||||||
|
private initialized: boolean = false;
|
||||||
|
|
||||||
private constructor() {}
|
private constructor() {}
|
||||||
|
|
||||||
@@ -62,67 +38,40 @@ export class RuntimeResolver {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize the runtime resolver
|
|
||||||
* 初始化运行时解析器
|
* 初始化运行时解析器
|
||||||
|
* Initialize the runtime resolver
|
||||||
*/
|
*/
|
||||||
async initialize(): Promise<void> {
|
async initialize(): Promise<void> {
|
||||||
// Load runtime configuration
|
if (this.initialized) return;
|
||||||
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();
|
|
||||||
|
|
||||||
// 查找 workspace 根目录
|
// 查找工作区根目录 | Find workspace root
|
||||||
const currentDir = await TauriAPI.getCurrentDir();
|
const currentDir = await TauriAPI.getCurrentDir();
|
||||||
const workspaceRoot = await this.findWorkspaceRoot(currentDir);
|
this.baseDir = await this.findWorkspaceRoot(currentDir);
|
||||||
|
|
||||||
// 优先使用 workspace 中的开发文件(如果存在)
|
// 查找引擎模块路径 | Find engine modules path
|
||||||
// Prefer workspace dev files if they exist
|
this.engineModulesPath = await this.findEngineModulesPath();
|
||||||
if (await this.hasRuntimeFilesInWorkspace(workspaceRoot)) {
|
|
||||||
this.baseDir = workspaceRoot;
|
this.initialized = true;
|
||||||
this.isDev = true;
|
|
||||||
} else {
|
|
||||||
// 回退到打包的资源目录(生产模式)
|
|
||||||
this.baseDir = await TauriAPI.getAppResourceDir();
|
|
||||||
this.isDev = false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if runtime files exist in workspace
|
* 查找工作区根目录
|
||||||
* 检查 workspace 中是否存在运行时文件
|
* Find workspace root by looking for workspace markers
|
||||||
*/
|
|
||||||
private async hasRuntimeFilesInWorkspace(workspaceRoot: string): Promise<boolean> {
|
|
||||||
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 或特定标记来找到工作区根目录
|
|
||||||
*/
|
*/
|
||||||
private async findWorkspaceRoot(startPath: string): Promise<string> {
|
private async findWorkspaceRoot(startPath: string): Promise<string> {
|
||||||
let currentPath = startPath;
|
let currentPath = startPath;
|
||||||
|
|
||||||
// Try to find the workspace root by looking for key files
|
for (let i = 0; i < 5; i++) {
|
||||||
// We'll check up to 3 levels up from current directory
|
// 检查是否在 src-tauri 目录 | Check if we're in src-tauri
|
||||||
for (let i = 0; i < 3; i++) {
|
|
||||||
// Check if we're in src-tauri
|
|
||||||
if (currentPath.endsWith('src-tauri')) {
|
if (currentPath.endsWith('src-tauri')) {
|
||||||
// Go up two levels to get to workspace root
|
|
||||||
const parts = currentPath.split(/[/\\]/);
|
const parts = currentPath.split(/[/\\]/);
|
||||||
parts.pop(); // Remove src-tauri
|
parts.pop();
|
||||||
parts.pop(); // Remove editor-app
|
parts.pop();
|
||||||
parts.pop(); // Remove packages
|
parts.pop();
|
||||||
return parts.join('\\');
|
return parts.join('\\');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for workspace markers
|
// 检查工作区标记 | Check for workspace markers
|
||||||
const workspaceMarkers = [
|
const workspaceMarkers = [
|
||||||
`${currentPath}\\pnpm-workspace.yaml`,
|
`${currentPath}\\pnpm-workspace.yaml`,
|
||||||
`${currentPath}\\packages\\editor-app`,
|
`${currentPath}\\packages\\editor-app`,
|
||||||
@@ -141,103 +90,336 @@ export class RuntimeResolver {
|
|||||||
currentPath = parts.join('\\');
|
currentPath = parts.join('\\');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback to current directory
|
|
||||||
return startPath;
|
return startPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get runtime module files
|
* Find engine modules path (where compiled modules with module.json are)
|
||||||
* 获取运行时模块文件
|
* 查找引擎模块路径(编译后的模块和 module.json 所在位置)
|
||||||
*/
|
*/
|
||||||
async getModuleFiles(moduleName: string): Promise<RuntimeModule> {
|
private async findEngineModulesPath(): Promise<string> {
|
||||||
if (!this.config) {
|
// Try installed editor location first
|
||||||
await this.initialize();
|
const installedPath = 'C:/Program Files/ESEngine Editor/engine';
|
||||||
|
if (await TauriAPI.pathExists(`${installedPath}/index.json`)) {
|
||||||
|
return installedPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
const moduleConfig = this.config!.runtime.modules[moduleName];
|
// Try workspace packages directory (dev mode)
|
||||||
if (!moduleConfig) {
|
const workspacePath = `${this.baseDir}\\packages`;
|
||||||
throw new Error(`Runtime module ${moduleName} not found in configuration`);
|
if (await TauriAPI.pathExists(`${workspacePath}\\core\\module.json`)) {
|
||||||
|
return workspacePath;
|
||||||
}
|
}
|
||||||
|
|
||||||
const files: string[] = [];
|
return workspacePath;
|
||||||
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
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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<void> {
|
async getAvailableModules(): Promise<ModuleManifest[]> {
|
||||||
|
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<string>();
|
||||||
|
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<string, string> }> {
|
||||||
|
if (!this.initialized) {
|
||||||
|
await this.initialize();
|
||||||
|
}
|
||||||
|
|
||||||
// Ensure target directory exists
|
// Ensure target directory exists
|
||||||
const dirExists = await TauriAPI.pathExists(targetDir);
|
if (!await TauriAPI.pathExists(targetDir)) {
|
||||||
if (!dirExists) {
|
|
||||||
await TauriAPI.createDirectory(targetDir);
|
await TauriAPI.createDirectory(targetDir);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Copy platform-web runtime
|
const libsDir = `${targetDir}\\libs`;
|
||||||
const platformWeb = await this.getModuleFiles('platform-web');
|
if (!await TauriAPI.pathExists(libsDir)) {
|
||||||
for (const srcFile of platformWeb.files) {
|
await TauriAPI.createDirectory(libsDir);
|
||||||
const filename = srcFile.split(/[/\\]/).pop() || '';
|
}
|
||||||
const dstFile = `${targetDir}\\${filename}`;
|
|
||||||
|
|
||||||
const srcExists = await TauriAPI.pathExists(srcFile);
|
const modules = await this.getAvailableModules();
|
||||||
if (srcExists) {
|
const importMap: Record<string, string> = {};
|
||||||
|
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);
|
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
|
// Copy external dependencies (e.g., rapier2d)
|
||||||
const engine = await this.getModuleFiles('engine');
|
await this.copyExternalDependencies(modules, libsDir, importMap);
|
||||||
for (const srcFile of engine.files) {
|
|
||||||
const filename = srcFile.split(/[/\\]/).pop() || '';
|
|
||||||
const dstFile = `${targetDir}\\${filename}`;
|
|
||||||
|
|
||||||
const srcExists = await TauriAPI.pathExists(srcFile);
|
// Copy engine WASM files to libs/es-engine/
|
||||||
if (srcExists) {
|
await this.copyEngineWasm(libsDir);
|
||||||
await TauriAPI.copyFile(srcFile, dstFile);
|
|
||||||
} else {
|
// Copy module-specific WASM files
|
||||||
throw new Error(`Engine file not found: ${srcFile}`);
|
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<void> {
|
||||||
|
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<string, string>
|
||||||
|
): Promise<void> {
|
||||||
|
const externalDeps = new Set<string>();
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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, string>): string {
|
||||||
|
return `<script type="importmap">
|
||||||
|
${JSON.stringify({ imports: importMap }, null, 2).split('\n').join('\n ')}
|
||||||
|
</script>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -247,4 +429,12 @@ export class RuntimeResolver {
|
|||||||
getBaseDir(): string {
|
getBaseDir(): string {
|
||||||
return this.baseDir;
|
return this.baseDir;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get engine modules path
|
||||||
|
* 获取引擎模块路径
|
||||||
|
*/
|
||||||
|
getEngineModulesPath(): string {
|
||||||
|
return this.engineModulesPath;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
80
packages/editor-app/src/services/TauriAssetReader.ts
Normal file
80
packages/editor-app/src/services/TauriAssetReader.ts
Normal file
@@ -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<string> {
|
||||||
|
return await invoke<string>('read_file_content', { path: absolutePath });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read file as binary.
|
||||||
|
* 读取文件为二进制。
|
||||||
|
*/
|
||||||
|
async readBinary(absolutePath: string): Promise<ArrayBuffer> {
|
||||||
|
const bytes = await invoke<number[]>('read_binary_file', { filePath: absolutePath });
|
||||||
|
return new Uint8Array(bytes).buffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load image from file.
|
||||||
|
* 从文件加载图片。
|
||||||
|
*/
|
||||||
|
async loadImage(absolutePath: string): Promise<HTMLImageElement> {
|
||||||
|
// 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<AudioBuffer> {
|
||||||
|
const binary = await this.readBinary(absolutePath);
|
||||||
|
const audioContext = new AudioContext();
|
||||||
|
return await audioContext.decodeAudioData(binary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if file exists.
|
||||||
|
* 检查文件是否存在。
|
||||||
|
*/
|
||||||
|
async exists(absolutePath: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await invoke('read_file_content', { path: absolutePath });
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -51,7 +51,7 @@ export class TauriFileSystemService implements IFileSystem {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async scanFiles(basePath: string, pattern: string): Promise<string[]> {
|
async scanFiles(basePath: string, pattern: string): Promise<string[]> {
|
||||||
return await invoke<string[]>('scan_files', { basePath, pattern });
|
return await invoke<string[]>('scan_directory', { path: basePath, pattern });
|
||||||
}
|
}
|
||||||
|
|
||||||
convertToAssetUrl(filePath: string): string {
|
convertToAssetUrl(filePath: string): string {
|
||||||
|
|||||||
143
packages/editor-app/src/services/TauriModuleFileSystem.ts
Normal file
143
packages/editor-app/src/services/TauriModuleFileSystem.ts
Normal file
@@ -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<T>(path: string): Promise<T> {
|
||||||
|
// Check if reading index.json
|
||||||
|
// 检查是否读取 index.json
|
||||||
|
if (path.endsWith('/index.json') || path === 'index.json') {
|
||||||
|
const index = await invoke<ModuleIndex>('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<T>('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<void> {
|
||||||
|
throw new Error('Write operation not supported for engine modules');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if path exists.
|
||||||
|
* 检查路径是否存在。
|
||||||
|
*/
|
||||||
|
async pathExists(path: string): Promise<boolean> {
|
||||||
|
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<string[]> {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read file as text.
|
||||||
|
* 读取文件为文本。
|
||||||
|
*/
|
||||||
|
async readText(path: string): Promise<string> {
|
||||||
|
const json = await this.readJson(path);
|
||||||
|
return JSON.stringify(json);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the base path to engine modules.
|
||||||
|
* 获取引擎模块的基础路径。
|
||||||
|
*/
|
||||||
|
async getBasePath(): Promise<string> {
|
||||||
|
if (!this._basePath) {
|
||||||
|
this._basePath = await invoke<string>('get_engine_modules_base_path');
|
||||||
|
}
|
||||||
|
return this._basePath;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
120
packages/editor-app/src/services/ViewportService.ts
Normal file
120
packages/editor-app/src/services/ViewportService.ts
Normal file
@@ -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<number> {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
909
packages/editor-app/src/styles/BuildSettingsPanel.css
Normal file
909
packages/editor-app/src/styles/BuildSettingsPanel.css
Normal file
@@ -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;
|
||||||
|
}
|
||||||
73
packages/editor-app/src/styles/BuildSettingsWindow.css
Normal file
73
packages/editor-app/src/styles/BuildSettingsWindow.css
Normal file
@@ -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;
|
||||||
|
}
|
||||||
104
packages/editor-app/src/styles/EditorViewport.css
Normal file
104
packages/editor-app/src/styles/EditorViewport.css
Normal file
@@ -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;
|
||||||
|
}
|
||||||
@@ -442,7 +442,8 @@
|
|||||||
|
|
||||||
/* ==================== Special Types ==================== */
|
/* ==================== Special Types ==================== */
|
||||||
.settings-plugin-list,
|
.settings-plugin-list,
|
||||||
.settings-custom-renderer {
|
.settings-custom-renderer,
|
||||||
|
.settings-module-list {
|
||||||
padding: 8px 16px;
|
padding: 8px 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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<string>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<string, unknown> = {};
|
||||||
|
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 host = process.env.TAURI_DEV_HOST;
|
||||||
const wasmPackages: string[] = [];
|
const wasmPackages: string[] = [];
|
||||||
|
|
||||||
@@ -161,6 +497,7 @@ export default defineConfig({
|
|||||||
tsDecorators: true,
|
tsDecorators: true,
|
||||||
}),
|
}),
|
||||||
copyPluginModulesPlugin(),
|
copyPluginModulesPlugin(),
|
||||||
|
copyEngineModulesPlugin(),
|
||||||
],
|
],
|
||||||
clearScreen: false,
|
clearScreen: false,
|
||||||
server: {
|
server: {
|
||||||
|
|||||||
Reference in New Issue
Block a user