feat: 预制体系统与架构改进 (#303)

* feat(prefab): 实现预制体系统和编辑器 UX 改进

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* fix(build): 添加缺失的 IAssetManager 导入
This commit is contained in:
YHH
2025-12-13 19:44:08 +08:00
committed by GitHub
parent a716d8006c
commit beaa1d09de
258 changed files with 17725 additions and 3030 deletions

View File

@@ -106,6 +106,53 @@ pub struct FileChangeEvent {
pub paths: Vec<String>,
}
/// Install esbuild globally using npm.
/// 使用 npm 全局安装 esbuild。
///
/// # Returns | 返回
/// Progress messages as the installation proceeds.
/// 安装过程中的进度消息。
#[command]
pub async fn install_esbuild(app: AppHandle) -> Result<(), String> {
println!("[Environment] Starting esbuild installation...");
// Emit progress event | 发送进度事件
let _ = app.emit("esbuild-install:progress", "Checking npm...");
// Check if npm is available | 检查 npm 是否可用
let npm_cmd = if cfg!(windows) { "npm.cmd" } else { "npm" };
let npm_check = Command::new(npm_cmd)
.arg("--version")
.output()
.map_err(|_| "npm not found. Please install Node.js first. | 未找到 npm请先安装 Node.js。".to_string())?;
if !npm_check.status.success() {
return Err("npm not working properly. | npm 无法正常工作。".to_string());
}
let _ = app.emit("esbuild-install:progress", "Installing esbuild globally...");
println!("[Environment] Running: npm install -g esbuild");
// Install esbuild globally | 全局安装 esbuild
let output = Command::new(npm_cmd)
.args(&["install", "-g", "esbuild"])
.output()
.map_err(|e| format!("Failed to run npm install | npm install 执行失败: {}", e))?;
if output.status.success() {
println!("[Environment] esbuild installed successfully");
let _ = app.emit("esbuild-install:progress", "Installation complete!");
let _ = app.emit("esbuild-install:success", true);
Ok(())
} else {
let stderr = String::from_utf8_lossy(&output.stderr);
let error_msg = format!("Failed to install esbuild | 安装 esbuild 失败: {}", stderr);
println!("[Environment] {}", error_msg);
let _ = app.emit("esbuild-install:error", &error_msg);
Err(error_msg)
}
}
/// Check development environment.
/// 检测开发环境。
///
@@ -123,27 +170,12 @@ pub async fn check_environment() -> Result<EnvironmentCheckResult, String> {
/// Check esbuild availability and get its status.
/// 检查 esbuild 可用性并获取其状态。
///
/// Only checks for globally installed esbuild (via npm -g).
/// 只检测通过 npm 全局安装的 esbuild。
fn check_esbuild_status() -> ToolStatus {
// Try bundled esbuild first | 首先尝试打包的 esbuild
if let Some(bundled_path) = find_bundled_esbuild() {
match get_esbuild_version(&bundled_path) {
Ok(version) => {
return ToolStatus {
available: true,
version: Some(version),
path: Some(bundled_path),
source: Some("bundled".to_string()),
error: None,
};
}
Err(e) => {
println!("[Environment] Bundled esbuild found but failed to get version: {}", e);
}
}
}
// Try global esbuild | 尝试全局 esbuild
let global_esbuild = if cfg!(windows) { "esbuild.cmd" } else { "esbuild" };
match get_esbuild_version(global_esbuild) {
Ok(version) => {
ToolStatus {
@@ -160,7 +192,7 @@ fn check_esbuild_status() -> ToolStatus {
version: None,
path: None,
source: None,
error: Some("esbuild not found | 未找到 esbuild".to_string()),
error: Some("esbuild not installed globally. Please install: npm install -g esbuild | 未全局安装 esbuild请安装: npm install -g esbuild".to_string()),
}
}
}
@@ -436,6 +468,204 @@ pub async fn watch_scripts(
Ok(())
}
/// Watch for file changes in asset directories.
/// 监视资产目录中的文件变更。
///
/// Watches multiple directories (assets, scenes, etc.) for all file types.
/// 监视多个目录assets, scenes 等)中的所有文件类型。
///
/// Emits "user-code:file-changed" events when files change.
/// 当文件发生变更时触发 "user-code:file-changed" 事件。
#[command]
pub async fn watch_assets(
app: AppHandle,
watcher_state: State<'_, ScriptWatcherState>,
project_path: String,
directories: Vec<String>,
) -> Result<(), String> {
// Create a unique key for this watcher set | 为此监视器集创建唯一键
let watcher_key = format!("{}/assets", project_path);
// Check if already watching | 检查是否已在监视
{
let watchers = watcher_state.watchers.lock().await;
if watchers.contains_key(&watcher_key) {
println!("[AssetWatcher] Already watching: {}", watcher_key);
return Ok(());
}
}
// Validate directories exist | 验证目录是否存在
let mut watch_paths = Vec::new();
for dir in &directories {
let watch_path = Path::new(&project_path).join(dir);
if watch_path.exists() {
watch_paths.push((watch_path, dir.clone()));
} else {
println!("[AssetWatcher] Directory does not exist, skipping: {}", watch_path.display());
}
}
if watch_paths.is_empty() {
return Err("No valid directories to watch | 没有有效的目录可监视".to_string());
}
// 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 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!("[AssetWatcher] Failed to create watcher: {}", e);
return;
}
};
// Start watching all directories | 开始监视所有目录
for (path, dir_name) in &watch_paths {
if let Err(e) = watcher.watch(path, RecursiveMode::Recursive) {
eprintln!("[AssetWatcher] Failed to watch {}: {}", dir_name, e);
} else {
println!("[AssetWatcher] Started watching: {}", path.display());
}
}
// Asset file extensions to monitor | 要监视的资产文件扩展名
let asset_extensions: std::collections::HashSet<&str> = [
// Images
"png", "jpg", "jpeg", "webp", "gif", "bmp", "svg",
// Audio
"mp3", "ogg", "wav", "flac", "m4a",
// Data formats
"json", "xml", "yaml", "yml", "txt",
// Custom asset types
"prefab", "ecs", "btree", "particle", "tmx", "tsx",
// Scripts (also watch these in assets dir)
"ts", "tsx", "js", "jsx",
// Materials and shaders
"mat", "shader", "glsl", "vert", "frag",
// Fonts
"ttf", "otf", "woff", "woff2",
// 3D assets
"gltf", "glb", "obj", "fbx",
].into_iter().collect();
// Debounce state | 防抖状态
let mut pending_events: std::collections::HashMap<String, String> = std::collections::HashMap::new();
let mut last_event_time = std::time::Instant::now();
let debounce_duration = Duration::from_millis(300);
// Event loop | 事件循环
loop {
// Check for shutdown | 检查关闭信号
if shutdown_rx.try_recv().is_ok() {
println!("[AssetWatcher] 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 asset files | 过滤资产文件
let valid_paths: Vec<(String, String)> = event
.paths
.iter()
.filter(|p| {
// Skip .meta files | 跳过 .meta 文件
if p.to_string_lossy().ends_with(".meta") {
return false;
}
// Check extension | 检查扩展名
let ext = p.extension().and_then(|e| e.to_str()).unwrap_or("");
asset_extensions.contains(ext.to_lowercase().as_str())
})
.map(|p| {
let path_str = p.to_string_lossy().to_string();
let change_type = match event.kind {
EventKind::Create(_) => "create",
EventKind::Modify(_) => "modify",
EventKind::Remove(_) => "remove",
_ => "modify",
};
(path_str, change_type.to_string())
})
.collect();
if !valid_paths.is_empty() {
// Only handle create/modify/remove events | 只处理创建/修改/删除事件
match event.kind {
EventKind::Create(_) | EventKind::Modify(_) | EventKind::Remove(_) => {
for (path, change_type) in valid_paths {
pending_events.insert(path, change_type);
}
last_event_time = std::time::Instant::now();
}
_ => continue,
};
}
}
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {
// Check if we should emit pending events (debounce) | 检查是否应该发送待处理事件(防抖)
if !pending_events.is_empty() && last_event_time.elapsed() >= debounce_duration {
// Group by change type | 按变更类型分组
let mut by_type: std::collections::HashMap<String, Vec<String>> = std::collections::HashMap::new();
for (path, change_type) in pending_events.drain() {
by_type.entry(change_type).or_default().push(path);
}
// Emit events for each type | 为每种类型发送事件
for (change_type, paths) in by_type {
let file_event = FileChangeEvent {
change_type,
paths,
};
println!("[AssetWatcher] File change detected (debounced): {:?}", file_event);
// Emit event to frontend | 向前端发送事件
if let Err(e) = app_clone.emit("user-code:file-changed", file_event) {
eprintln!("[AssetWatcher] Failed to emit event: {}", e);
}
}
}
}
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => {
println!("[AssetWatcher] Watcher channel disconnected");
break;
}
}
}
});
// Store watcher handle | 存储监视器句柄
{
let mut watchers = watcher_state.watchers.lock().await;
watchers.insert(
watcher_key.clone(),
crate::state::WatcherHandle { shutdown_tx },
);
}
println!("[AssetWatcher] Watch started for directories: {:?}", directories);
Ok(())
}
/// Stop watching for file changes.
/// 停止监视文件变更。
#[command]
@@ -468,32 +698,9 @@ pub async fn stop_watch_scripts(
/// Find esbuild executable path.
/// 查找 esbuild 可执行文件路径。
///
/// Search order | 搜索顺序:
/// 1. Bundled esbuild in app resources | 应用资源中打包的 esbuild
/// 2. Local node_modules | 本地 node_modules
/// 3. Global esbuild | 全局 esbuild
fn find_esbuild(project_root: &str) -> Result<String, String> {
let project_path = Path::new(project_root);
// Try bundled esbuild first (in app resources) | 首先尝试打包的 esbuild在应用资源中
if let Some(bundled) = find_bundled_esbuild() {
println!("[Compiler] Using bundled esbuild: {}", bundled);
return Ok(bundled);
}
// Try local node_modules | 尝试本地 node_modules
let local_esbuild = if cfg!(windows) {
project_path.join("node_modules/.bin/esbuild.cmd")
} else {
project_path.join("node_modules/.bin/esbuild")
};
if local_esbuild.exists() {
println!("[Compiler] Using local esbuild: {}", local_esbuild.display());
return Ok(local_esbuild.to_string_lossy().to_string());
}
// Try global esbuild | 尝试全局 esbuild
/// Only uses globally installed esbuild (npm -g).
/// 只使用全局安装的 esbuild (npm -g)。
fn find_esbuild(_project_root: &str) -> Result<String, String> {
let global_esbuild = if cfg!(windows) { "esbuild.cmd" } else { "esbuild" };
// Check if global esbuild exists | 检查全局 esbuild 是否存在
@@ -506,47 +713,10 @@ fn find_esbuild(project_root: &str) -> Result<String, String> {
println!("[Compiler] Using global esbuild");
Ok(global_esbuild.to_string())
},
_ => Err("esbuild not found. Please install esbuild: npm install -g esbuild | 未找到 esbuild请安装: npm install -g esbuild".to_string())
_ => Err("esbuild not installed globally. Please install: npm install -g esbuild | 未全局安装 esbuild请安装: npm install -g esbuild".to_string())
}
}
/// Find bundled esbuild in app resources.
/// 在应用资源中查找打包的 esbuild。
fn find_bundled_esbuild() -> Option<String> {
// Get the executable path | 获取可执行文件路径
let exe_path = std::env::current_exe().ok()?;
let exe_dir = exe_path.parent()?;
// In development, resources are in src-tauri directory | 开发模式下,资源在 src-tauri 目录
// In production, resources are next to the executable | 生产模式下,资源在可执行文件旁边
let esbuild_name = if cfg!(windows) { "esbuild.exe" } else { "esbuild" };
// Try production path (resources next to exe) | 尝试生产路径(资源在 exe 旁边)
let prod_path = exe_dir.join("bin").join(esbuild_name);
if prod_path.exists() {
return Some(prod_path.to_string_lossy().to_string());
}
// Try development path (in src-tauri/bin) | 尝试开发路径(在 src-tauri/bin 中)
// This handles running via `cargo tauri dev`
let dev_path = exe_dir
.ancestors()
.find_map(|p| {
let candidate = p.join("src-tauri").join("bin").join(esbuild_name);
if candidate.exists() {
Some(candidate)
} else {
None
}
});
if let Some(path) = dev_path {
return Some(path.to_string_lossy().to_string());
}
None
}
/// Parse esbuild error output.
/// 解析 esbuild 错误输出。
fn parse_esbuild_errors(stderr: &str) -> Vec<CompileError> {

View File

@@ -65,11 +65,13 @@ pub async fn open_file_dialog(
}
/// Save file dialog (generic)
/// 通用保存文件对话框
#[tauri::command]
pub async fn save_file_dialog(
app: AppHandle,
title: Option<String>,
default_name: Option<String>,
default_path: Option<String>,
filters: Option<Vec<FileFilter>>,
) -> Result<Option<String>, String> {
let mut dialog = app.dialog().file();
@@ -80,6 +82,14 @@ pub async fn save_file_dialog(
dialog = dialog.set_title("Save File");
}
// Set default directory | 设置默认目录
if let Some(path) = default_path {
let path_buf = std::path::PathBuf::from(&path);
if path_buf.exists() {
dialog = dialog.set_directory(&path_buf);
}
}
if let Some(name) = default_name {
dialog = dialog.set_file_name(&name);
}

View File

@@ -23,6 +23,31 @@ pub fn read_file_content(path: String) -> Result<String, String> {
.map_err(|e| format!("Failed to read file {}: {}", path, e))
}
/// Append text to log file (auto-creates parent directories)
/// 追加文本到日志文件(自动创建父目录)
#[tauri::command]
pub fn append_to_log(path: String, content: String) -> Result<(), String> {
use std::fs::OpenOptions;
use std::io::Write;
// Ensure parent directory exists
if let Some(parent) = Path::new(&path).parent() {
if !parent.exists() {
fs::create_dir_all(parent)
.map_err(|e| format!("Failed to create directory {}: {}", parent.display(), e))?;
}
}
let mut file = OpenOptions::new()
.create(true)
.append(true)
.open(&path)
.map_err(|e| format!("Failed to open log file {}: {}", path, e))?;
writeln!(file, "{}", content)
.map_err(|e| format!("Failed to write to log file {}: {}", path, e))
}
/// Write text content to file (auto-creates parent directories)
#[tauri::command]
pub fn write_file_content(path: String, content: String) -> Result<(), String> {

View File

@@ -72,6 +72,38 @@ pub fn open_file_with_default_app(file_path: String) -> Result<(), String> {
Ok(())
}
/// Open folder in system file explorer
/// 在系统文件管理器中打开文件夹
#[tauri::command]
pub fn open_folder(path: String) -> Result<(), String> {
#[cfg(target_os = "windows")]
{
let normalized_path = path.replace('/', "\\");
Command::new("explorer")
.arg(&normalized_path)
.spawn()
.map_err(|e| format!("Failed to open folder: {}", e))?;
}
#[cfg(target_os = "macos")]
{
Command::new("open")
.arg(&path)
.spawn()
.map_err(|e| format!("Failed to open folder: {}", e))?;
}
#[cfg(target_os = "linux")]
{
Command::new("xdg-open")
.arg(&path)
.spawn()
.map_err(|e| format!("Failed to open folder: {}", e))?;
}
Ok(())
}
/// Show file in system file explorer
#[tauri::command]
pub fn show_in_folder(file_path: String) -> Result<(), String> {
@@ -344,7 +376,6 @@ pub fn get_current_dir() -> Result<String, String> {
/// 扫描 dist/engine/ 目录,为所有有 .d.ts 文件的模块添加路径。
#[tauri::command]
pub fn update_project_tsconfig(app: AppHandle, project_path: String) -> Result<(), String> {
use std::fs;
use std::path::Path;
let project = Path::new(&project_path);
@@ -558,11 +589,18 @@ pub fn start_local_server(root_path: String, port: u16) -> Result<String, String
// Handle different request types
let file_path = if url.starts_with("/asset?path=") {
// Asset proxy - extract and decode path parameter
// 资产代理 - 提取并解码路径参数
let query = &url[7..]; // Skip "/asset?"
if let Some(path_value) = query.strip_prefix("path=") {
urlencoding::decode(path_value)
let decoded = urlencoding::decode(path_value)
.map(|s| s.to_string())
.unwrap_or_default()
.unwrap_or_default();
// Normalize path: remove ./ prefix and join with root
// 规范化路径:移除 ./ 前缀并与根目录连接
let normalized = decoded.trim_start_matches("./");
PathBuf::from(&root).join(normalized)
.to_string_lossy()
.to_string()
} else {
String::new()
}

View File

@@ -54,6 +54,7 @@ fn main() {
commands::read_file_content,
commands::write_file_content,
commands::write_binary_file,
commands::append_to_log,
commands::path_exists,
commands::create_directory,
commands::create_file,
@@ -79,6 +80,7 @@ fn main() {
// System operations
commands::toggle_devtools,
commands::open_file_with_default_app,
commands::open_folder,
commands::show_in_folder,
commands::get_temp_dir,
commands::open_with_editor,
@@ -92,8 +94,10 @@ fn main() {
// User code compilation | 用户代码编译
commands::compile_typescript,
commands::watch_scripts,
commands::watch_assets,
commands::stop_watch_scripts,
commands::check_environment,
commands::install_esbuild,
// Build commands | 构建命令
commands::prepare_build_directory,
commands::copy_directory,