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