feat(editor-app): 重构浏览器预览使用import maps

This commit is contained in:
yhh
2025-12-03 16:19:50 +08:00
parent 55f644a091
commit c2f8cb5272
50 changed files with 5995 additions and 1499 deletions

View File

@@ -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:*",

View File

@@ -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"

View File

@@ -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

View 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))
}

View 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
}

View File

@@ -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::*;

View 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())
}

View File

@@ -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");

View File

@@ -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.

View File

@@ -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}

View File

@@ -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
}) })

View File

@@ -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) {

View File

@@ -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
}; };
} }

View 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;

View 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;

View File

@@ -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) {

View 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

View File

@@ -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 },

View 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;

View File

@@ -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">

View File

@@ -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>

View File

@@ -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;
} }

View File

@@ -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 },

View File

@@ -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`;

View File

@@ -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} />;
} }

View File

@@ -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 元素

View 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);
}

View File

@@ -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"
}
} }
} }

View File

@@ -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": "材质实体"
}
} }
} }

View File

@@ -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()
}; };

View File

@@ -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()
}; };

View File

@@ -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()
}; };

View File

@@ -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()
}; };

View File

@@ -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()
}; };

View File

@@ -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()
}; };

View File

@@ -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';

View 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();

View File

@@ -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) {

View File

@@ -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' &&

View File

@@ -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;
}
} }

View 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;
}
}
}

View File

@@ -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 {

View 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;
}
}

View 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
}
}

View 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;
}

View 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;
}

View 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;
}

View File

@@ -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;
} }

View File

@@ -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/rapier2dWASM 在 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: {