Files
esengine/packages/asset-system/src/integration/EngineIntegration.ts
YHH beaa1d09de 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 导入
2025-12-13 19:44:08 +08:00

646 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Engine integration for asset system
* 资产系统的引擎集成
*/
import { AssetManager } from '../core/AssetManager';
import { AssetGUID, AssetType } from '../types/AssetTypes';
import { ITextureAsset, IAudioAsset, IJsonAsset } from '../interfaces/IAssetLoader';
import { PathResolutionService, type IPathResolutionService } from '../services/PathResolutionService';
import { TextureLoader } from '../loaders/TextureLoader';
/**
* Texture engine bridge interface (for asset system)
* 纹理引擎桥接接口(用于资产系统)
*/
export interface ITextureEngineBridge {
/**
* Load texture to GPU
* 加载纹理到GPU
*/
loadTexture(id: number, url: string): Promise<void>;
/**
* Load multiple textures
* 批量加载纹理
*/
loadTextures(requests: Array<{ id: number; url: string }>): Promise<void>;
/**
* Unload texture from GPU
* 从GPU卸载纹理
*/
unloadTexture(id: number): void;
/**
* Get texture info
* 获取纹理信息
*/
getTextureInfo(id: number): { width: number; height: number } | null;
/**
* Get or load texture by path.
* 按路径获取或加载纹理。
*
* This is the preferred method for getting texture IDs.
* The Rust engine is the single source of truth for texture ID allocation.
* 这是获取纹理 ID 的首选方法。
* Rust 引擎是纹理 ID 分配的唯一事实来源。
*
* @param path Image path/URL | 图片路径/URL
* @returns Texture ID allocated by Rust engine | Rust 引擎分配的纹理 ID
*/
getOrLoadTextureByPath?(path: string): number;
/**
* Clear the texture path cache (optional).
* 清除纹理路径缓存(可选)。
*
* This should be called when restoring scene snapshots to ensure
* textures are reloaded with correct IDs.
* 在恢复场景快照时应调用此方法以确保纹理使用正确的ID重新加载。
*/
clearTexturePathCache?(): void;
/**
* Clear all textures and reset state (optional).
* 清除所有纹理并重置状态(可选)。
*/
clearAllTextures?(): void;
}
/**
* Audio asset with runtime ID
* 带运行时 ID 的音频资产
*/
interface AudioAssetEntry {
id: number;
asset: IAudioAsset;
path: string;
}
/**
* Data asset with runtime ID
* 带运行时 ID 的数据资产
*/
interface DataAssetEntry {
id: number;
data: unknown;
path: string;
}
/**
* Asset system engine integration
* 资产系统引擎集成
*/
export class EngineIntegration {
private _assetManager: AssetManager;
private _engineBridge?: ITextureEngineBridge;
private _pathResolver: IPathResolutionService;
private _textureIdMap = new Map<AssetGUID, number>();
private _pathToTextureId = new Map<string, number>();
// Audio resource mappings | 音频资源映射
private _audioIdMap = new Map<AssetGUID, number>();
private _pathToAudioId = new Map<string, number>();
private _audioAssets = new Map<number, AudioAssetEntry>();
private static _nextAudioId = 1;
// Data resource mappings | 数据资源映射
private _dataIdMap = new Map<AssetGUID, number>();
private _pathToDataId = new Map<string, number>();
private _dataAssets = new Map<number, DataAssetEntry>();
private static _nextDataId = 1;
constructor(assetManager: AssetManager, engineBridge?: ITextureEngineBridge, pathResolver?: IPathResolutionService) {
this._assetManager = assetManager;
this._engineBridge = engineBridge;
this._pathResolver = pathResolver ?? new PathResolutionService();
}
/**
* Set path resolver
* 设置路径解析器
*/
setPathResolver(resolver: IPathResolutionService): void {
this._pathResolver = resolver;
}
/**
* Set engine bridge
* 设置引擎桥接
*/
setEngineBridge(bridge: ITextureEngineBridge): void {
this._engineBridge = bridge;
}
/**
* Load texture for component
* 为组件加载纹理
*
* 使用 Rust 引擎作为纹理 ID 的唯一分配源。
* Uses Rust engine as the single source of truth for texture ID allocation.
*
* AssetManager 内部会处理路径解析,这里只需传入原始路径。
* AssetManager handles path resolution internally, just pass the original path here.
*/
async loadTextureForComponent(texturePath: string): Promise<number> {
// 检查缓存(使用原始路径作为键)
// Check cache (using original path as key)
const existingId = this._pathToTextureId.get(texturePath);
if (existingId) {
return existingId;
}
// 解析路径为引擎可用的 URL
// Resolve path to engine-compatible URL
const engineUrl = this._pathResolver.catalogToRuntime(texturePath);
// 优先使用 getOrLoadTextureByPathRust 分配 ID
// Prefer getOrLoadTextureByPath (Rust allocates ID)
// 这确保纹理 ID 由 Rust 引擎统一分配,避免 JS/Rust 层 ID 不同步问题
// This ensures texture IDs are allocated by Rust engine uniformly,
// avoiding JS/Rust layer ID desync issues
if (this._engineBridge?.getOrLoadTextureByPath) {
const rustTextureId = this._engineBridge.getOrLoadTextureByPath(engineUrl);
if (rustTextureId > 0) {
// 缓存映射
// Cache mapping
this._pathToTextureId.set(texturePath, rustTextureId);
return rustTextureId;
}
}
// 回退:通过资产系统加载(兼容旧流程)
// Fallback: Load through asset system (for backward compatibility)
const result = await this._assetManager.loadAssetByPath<ITextureAsset>(texturePath);
const textureAsset = result.asset;
// 如果有引擎桥接上传到GPU
// Upload to GPU if bridge exists
if (this._engineBridge && textureAsset.data) {
await this._engineBridge.loadTexture(textureAsset.textureId, engineUrl);
}
// 缓存映射(使用原始路径作为键,避免重复解析)
// Cache mapping (using original path as key to avoid re-resolving)
this._pathToTextureId.set(texturePath, textureAsset.textureId);
return textureAsset.textureId;
}
/**
* Load texture by GUID
* 通过GUID加载纹理
*
* 使用 Rust 引擎作为纹理 ID 的唯一分配源。
* Uses Rust engine as the single source of truth for texture ID allocation.
*/
async loadTextureByGuid(guid: AssetGUID): Promise<number> {
// 检查是否已有纹理ID / Check if texture ID exists
const existingId = this._textureIdMap.get(guid);
if (existingId) {
return existingId;
}
// 通过资产系统加载获取元数据和路径 / Load through asset system to get metadata and path
const result = await this._assetManager.loadAsset<ITextureAsset>(guid);
const metadata = result.metadata;
const engineUrl = this._pathResolver.catalogToRuntime(metadata.path);
// 优先使用 getOrLoadTextureByPathRust 分配 ID
// Prefer getOrLoadTextureByPath (Rust allocates ID)
if (this._engineBridge?.getOrLoadTextureByPath) {
const rustTextureId = this._engineBridge.getOrLoadTextureByPath(engineUrl);
if (rustTextureId > 0) {
// 缓存映射
// Cache mapping
this._textureIdMap.set(guid, rustTextureId);
return rustTextureId;
}
}
// 回退:使用 TextureLoader 分配的 ID兼容旧流程
// Fallback: Use TextureLoader allocated ID (for backward compatibility)
const textureAsset = result.asset;
if (this._engineBridge && textureAsset.data) {
await this._engineBridge.loadTexture(textureAsset.textureId, engineUrl);
}
// 缓存映射 / Cache mapping
this._textureIdMap.set(guid, textureAsset.textureId);
return textureAsset.textureId;
}
/**
* Batch load textures
* 批量加载纹理
*/
async loadTexturesBatch(paths: string[]): Promise<Map<string, number>> {
const results = new Map<string, number>();
// 收集需要加载的纹理 / Collect textures to load
const toLoad: string[] = [];
for (const path of paths) {
const existingId = this._pathToTextureId.get(path);
if (existingId) {
results.set(path, existingId);
} else {
toLoad.push(path);
}
}
if (toLoad.length === 0) {
return results;
}
// 并行加载所有纹理 / Load all textures in parallel
const loadPromises = toLoad.map(async (path) => {
try {
const id = await this.loadTextureForComponent(path);
results.set(path, id);
} catch (error) {
console.error(`Failed to load texture: ${path}`, error);
results.set(path, 0); // 使用默认纹理ID / Use default texture ID
}
});
await Promise.all(loadPromises);
return results;
}
/**
* 批量加载资源(通用方法,支持 IResourceLoader 接口)
* Load resources in batch (generic method for IResourceLoader interface)
*
* @param paths 资源路径数组 / Array of resource paths
* @param type 资源类型 / Resource type
* @returns 路径到运行时 ID 的映射 / Map of paths to runtime IDs
*/
async loadResourcesBatch(paths: string[], type: 'texture' | 'audio' | 'font' | 'data'): Promise<Map<string, number>> {
switch (type) {
case 'texture':
return this.loadTexturesBatch(paths);
case 'audio':
return this.loadAudioBatch(paths);
case 'data':
return this.loadDataBatch(paths);
case 'font':
// 字体资源暂未实现 / Font resources not yet implemented
console.warn('[EngineIntegration] Font resource loading not yet implemented');
return new Map();
default:
console.warn(`[EngineIntegration] Unknown resource type '${type}'`);
return new Map();
}
}
// ============= Audio Resource Methods =============
// ============= 音频资源方法 =============
/**
* Load audio for component
* 为组件加载音频
*
* @param audioPath 音频文件路径 / Audio file path
* @returns 运行时音频 ID / Runtime audio ID
*/
async loadAudioForComponent(audioPath: string): Promise<number> {
// 检查缓存 / Check cache
const existingId = this._pathToAudioId.get(audioPath);
if (existingId) {
return existingId;
}
// 通过资产系统加载 / Load through asset system
const result = await this._assetManager.loadAssetByPath<IAudioAsset>(audioPath);
const audioAsset = result.asset;
// 分配运行时 ID / Assign runtime ID
const audioId = EngineIntegration._nextAudioId++;
// 缓存映射 / Cache mapping
this._pathToAudioId.set(audioPath, audioId);
this._audioAssets.set(audioId, {
id: audioId,
asset: audioAsset,
path: audioPath
});
return audioId;
}
/**
* Batch load audio files
* 批量加载音频文件
*/
async loadAudioBatch(paths: string[]): Promise<Map<string, number>> {
const results = new Map<string, number>();
// 收集需要加载的音频 / Collect audio to load
const toLoad: string[] = [];
for (const path of paths) {
const existingId = this._pathToAudioId.get(path);
if (existingId) {
results.set(path, existingId);
} else {
toLoad.push(path);
}
}
if (toLoad.length === 0) {
return results;
}
// 并行加载所有音频 / Load all audio in parallel
const loadPromises = toLoad.map(async (path) => {
try {
const id = await this.loadAudioForComponent(path);
results.set(path, id);
} catch (error) {
console.error(`Failed to load audio: ${path}`, error);
results.set(path, 0);
}
});
await Promise.all(loadPromises);
return results;
}
/**
* Get audio asset by ID
* 通过 ID 获取音频资产
*/
getAudioAsset(audioId: number): IAudioAsset | null {
const entry = this._audioAssets.get(audioId);
return entry?.asset || null;
}
/**
* Get audio ID for path
* 获取路径的音频 ID
*/
getAudioId(path: string): number | null {
return this._pathToAudioId.get(path) || null;
}
/**
* Unload audio
* 卸载音频
*/
unloadAudio(audioId: number): void {
const entry = this._audioAssets.get(audioId);
if (entry) {
this._pathToAudioId.delete(entry.path);
this._audioAssets.delete(audioId);
// 从 GUID 映射中清理 / Clean up GUID mapping
for (const [guid, id] of this._audioIdMap.entries()) {
if (id === audioId) {
this._audioIdMap.delete(guid);
this._assetManager.unloadAsset(guid);
break;
}
}
}
}
// ============= Data Resource Methods =============
// ============= 数据资源方法 =============
/**
* Load data (JSON) for component
* 为组件加载数据JSON
*
* @param dataPath 数据文件路径 / Data file path
* @returns 运行时数据 ID / Runtime data ID
*/
async loadDataForComponent(dataPath: string): Promise<number> {
// 检查缓存 / Check cache
const existingId = this._pathToDataId.get(dataPath);
if (existingId) {
return existingId;
}
// 通过资产系统加载 / Load through asset system
const result = await this._assetManager.loadAssetByPath<IJsonAsset>(dataPath);
const jsonAsset = result.asset;
// 分配运行时 ID / Assign runtime ID
const dataId = EngineIntegration._nextDataId++;
// 缓存映射 / Cache mapping
this._pathToDataId.set(dataPath, dataId);
this._dataAssets.set(dataId, {
id: dataId,
data: jsonAsset.data,
path: dataPath
});
return dataId;
}
/**
* Batch load data files
* 批量加载数据文件
*/
async loadDataBatch(paths: string[]): Promise<Map<string, number>> {
const results = new Map<string, number>();
// 收集需要加载的数据 / Collect data to load
const toLoad: string[] = [];
for (const path of paths) {
const existingId = this._pathToDataId.get(path);
if (existingId) {
results.set(path, existingId);
} else {
toLoad.push(path);
}
}
if (toLoad.length === 0) {
return results;
}
// 并行加载所有数据 / Load all data in parallel
const loadPromises = toLoad.map(async (path) => {
try {
const id = await this.loadDataForComponent(path);
results.set(path, id);
} catch (error) {
console.error(`Failed to load data: ${path}`, error);
results.set(path, 0);
}
});
await Promise.all(loadPromises);
return results;
}
/**
* Get data by ID
* 通过 ID 获取数据
*/
getData<T = unknown>(dataId: number): T | null {
const entry = this._dataAssets.get(dataId);
return (entry?.data as T) || null;
}
/**
* Get data ID for path
* 获取路径的数据 ID
*/
getDataId(path: string): number | null {
return this._pathToDataId.get(path) || null;
}
/**
* Unload data
* 卸载数据
*/
unloadData(dataId: number): void {
const entry = this._dataAssets.get(dataId);
if (entry) {
this._pathToDataId.delete(entry.path);
this._dataAssets.delete(dataId);
// 从 GUID 映射中清理 / Clean up GUID mapping
for (const [guid, id] of this._dataIdMap.entries()) {
if (id === dataId) {
this._dataIdMap.delete(guid);
this._assetManager.unloadAsset(guid);
break;
}
}
}
}
/**
* Unload texture
* 卸载纹理
*/
unloadTexture(textureId: number): void {
// 从引擎卸载 / Unload from engine
if (this._engineBridge) {
this._engineBridge.unloadTexture(textureId);
}
// 清理映射 / Clean up mappings
for (const [path, id] of this._pathToTextureId.entries()) {
if (id === textureId) {
this._pathToTextureId.delete(path);
break;
}
}
for (const [guid, id] of this._textureIdMap.entries()) {
if (id === textureId) {
this._textureIdMap.delete(guid);
// 也从资产管理器卸载 / Also unload from asset manager
this._assetManager.unloadAsset(guid);
break;
}
}
}
/**
* Get texture ID for path
* 获取路径的纹理ID
*/
getTextureId(path: string): number | null {
return this._pathToTextureId.get(path) || null;
}
/**
* Preload textures for scene
* 为场景预加载纹理
*/
async preloadSceneTextures(texturePaths: string[]): Promise<void> {
await this.loadTexturesBatch(texturePaths);
}
/**
* Clear all texture mappings
* 清空所有纹理映射
*
* This clears both local texture ID mappings and the AssetManager's
* texture cache to ensure textures are fully reloaded.
* 这会清除本地纹理 ID 映射和 AssetManager 的纹理缓存,确保纹理完全重新加载。
*
* IMPORTANT: This also clears the Rust engine's texture cache to ensure
* both JS and Rust layers are in sync.
* 重要:这也会清除 Rust 引擎的纹理缓存,确保 JS 和 Rust 层同步。
*/
clearTextureMappings(): void {
// 1. 清除本地映射
// Clear local mappings
this._textureIdMap.clear();
this._pathToTextureId.clear();
// 2. 清除 Rust 引擎的纹理缓存(如果可用)
// Clear Rust engine's texture cache (if available)
// 这确保下次加载时 Rust 会重新分配 ID
// This ensures Rust will reallocate IDs on next load
if (this._engineBridge?.clearAllTextures) {
this._engineBridge.clearAllTextures();
}
// 3. 清除 AssetManager 中的纹理资产缓存
// Clear texture asset cache in AssetManager
// 强制清除以确保纹理使用新的 ID 重新加载
// Force clear to ensure textures are reloaded with new IDs
this._assetManager.unloadAssetsByType(AssetType.Texture, true);
// 4. 重置 TextureLoader 的 ID 计数器(保持向后兼容)
// Reset TextureLoader's ID counter (for backward compatibility)
TextureLoader.resetTextureIdCounter();
}
/**
* Clear all audio mappings
* 清空所有音频映射
*/
clearAudioMappings(): void {
this._audioIdMap.clear();
this._pathToAudioId.clear();
this._audioAssets.clear();
}
/**
* Clear all data mappings
* 清空所有数据映射
*/
clearDataMappings(): void {
this._dataIdMap.clear();
this._pathToDataId.clear();
this._dataAssets.clear();
}
/**
* Clear all resource mappings
* 清空所有资源映射
*/
clearAllMappings(): void {
this.clearTextureMappings();
this.clearAudioMappings();
this.clearDataMappings();
}
/**
* Get statistics
* 获取统计信息
*/
getStatistics(): {
loadedTextures: number;
loadedAudio: number;
loadedData: number;
} {
return {
loadedTextures: this._pathToTextureId.size,
loadedAudio: this._audioAssets.size,
loadedData: this._dataAssets.size
};
}
}