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:
@@ -117,9 +117,9 @@ export interface ISDKModuleConfig {
|
||||
readonly packageName: string;
|
||||
|
||||
/**
|
||||
* 全局变量键名
|
||||
* Global variable key name
|
||||
* @example 'ecsFramework' -> window.__ESENGINE__.ecsFramework
|
||||
* 全局变量键名(已废弃,现使用统一 SDK)
|
||||
* Global variable key name (deprecated, now using unified SDK)
|
||||
* @deprecated 使用 @esengine/sdk 代替 | Use @esengine/sdk instead
|
||||
*/
|
||||
readonly globalKey: string;
|
||||
|
||||
@@ -173,7 +173,7 @@ export const EditorConfig: IEditorConfig = {
|
||||
},
|
||||
|
||||
globals: {
|
||||
sdk: '__ESENGINE__',
|
||||
sdk: '__ESENGINE_SDK__',
|
||||
plugins: '__ESENGINE_PLUGINS__',
|
||||
userRuntimeExports: '__USER_RUNTIME_EXPORTS__',
|
||||
userEditorExports: '__USER_EDITOR_EXPORTS__',
|
||||
@@ -196,21 +196,9 @@ export const EditorConfig: IEditorConfig = {
|
||||
},
|
||||
|
||||
sdkModules: [
|
||||
// 核心模块 - 必须加载
|
||||
// Core modules - must be loaded
|
||||
{ packageName: '@esengine/ecs-framework', globalKey: 'ecsFramework', type: 'core' },
|
||||
|
||||
// 运行时模块 - 游戏运行时可用
|
||||
// Runtime modules - available at game runtime
|
||||
{ packageName: '@esengine/engine-core', globalKey: 'engineCore', type: 'runtime' },
|
||||
{ packageName: '@esengine/behavior-tree', globalKey: 'behaviorTree', type: 'runtime' },
|
||||
{ packageName: '@esengine/sprite', globalKey: 'sprite', type: 'runtime' },
|
||||
{ packageName: '@esengine/camera', globalKey: 'camera', type: 'runtime' },
|
||||
{ packageName: '@esengine/audio', globalKey: 'audio', type: 'runtime' },
|
||||
|
||||
// 编辑器模块 - 仅编辑器环境可用
|
||||
// Editor modules - only available in editor environment
|
||||
{ packageName: '@esengine/editor-runtime', globalKey: 'editorRuntime', type: 'editor' },
|
||||
// 统一 SDK 入口 - 用户代码唯一入口
|
||||
// Unified SDK entry - the only entry point for user code
|
||||
{ packageName: '@esengine/sdk', globalKey: 'sdk', type: 'core' },
|
||||
],
|
||||
} as const;
|
||||
|
||||
@@ -357,41 +345,31 @@ export function getEnabledSDKModules(type?: SDKModuleType): readonly ISDKModuleC
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 SDK 模块的全局变量映射
|
||||
* Get SDK modules global variable mapping
|
||||
* 获取 SDK 全局变量映射
|
||||
* Get SDK global variable mapping
|
||||
*
|
||||
* 用于生成插件构建配置的 globals 选项。
|
||||
* Used for generating plugins build config globals option.
|
||||
*
|
||||
* @returns 包名到全局变量路径的映射 | Mapping from package name to global variable path
|
||||
* @returns 包名到全局变量的映射 | Mapping from package name to global variable
|
||||
* @example
|
||||
* {
|
||||
* '@esengine/ecs-framework': '__ESENGINE__.ecsFramework',
|
||||
* '@esengine/behavior-tree': '__ESENGINE__.behaviorTree',
|
||||
* '@esengine/sdk': '__ESENGINE_SDK__',
|
||||
* }
|
||||
*/
|
||||
export function getSDKGlobalsMapping(): Record<string, string> {
|
||||
const sdkGlobalName = EditorConfig.globals.sdk;
|
||||
const mapping: Record<string, string> = {};
|
||||
|
||||
for (const module of EditorConfig.sdkModules) {
|
||||
if (module.enabled !== false) {
|
||||
mapping[module.packageName] = `${sdkGlobalName}.${module.globalKey}`;
|
||||
}
|
||||
}
|
||||
|
||||
return mapping;
|
||||
return {
|
||||
'@esengine/sdk': EditorConfig.globals.sdk
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有 SDK 包名列表
|
||||
* Get all SDK package names
|
||||
* 获取 SDK 包名
|
||||
* Get SDK package name
|
||||
*
|
||||
* 用于生成插件构建配置的 external 选项。
|
||||
* Used for generating plugins build config external option.
|
||||
*/
|
||||
export function getSDKPackageNames(): string[] {
|
||||
return EditorConfig.sdkModules
|
||||
.filter(m => m.enabled !== false)
|
||||
.map(m => m.packageName);
|
||||
return ['@esengine/sdk'];
|
||||
}
|
||||
|
||||
@@ -6,13 +6,17 @@
|
||||
* Re-export base types from @esengine/engine-core.
|
||||
*/
|
||||
|
||||
// 从 engine-core 导入类型
|
||||
// Import types from engine-core
|
||||
import type { IRuntimePlugin as IRuntimePluginBase } from '@esengine/engine-core';
|
||||
|
||||
// 从 engine-core 重新导出所有类型
|
||||
// 包括 IEditorModuleBase(原来在 plugin-types 中定义,现在统一从 engine-core 导出)
|
||||
export type {
|
||||
LoadingPhase,
|
||||
SystemContext,
|
||||
IRuntimeModule,
|
||||
IPlugin,
|
||||
IRuntimePlugin,
|
||||
ModuleManifest,
|
||||
ModuleCategory,
|
||||
ModulePlatform,
|
||||
@@ -20,6 +24,9 @@ export type {
|
||||
IEditorModuleBase
|
||||
} from '@esengine/engine-core';
|
||||
|
||||
/** @deprecated Use IRuntimePlugin instead */
|
||||
export type IPlugin<TEditorModule = unknown> = IRuntimePluginBase<TEditorModule>;
|
||||
|
||||
/**
|
||||
* 插件状态
|
||||
* Plugin state
|
||||
|
||||
@@ -289,9 +289,10 @@ export class AssetRegistryService implements IService {
|
||||
|
||||
/**
|
||||
* Handle project closed event
|
||||
* 处理项目关闭事件
|
||||
*/
|
||||
private _onProjectClosed(): void {
|
||||
this.unloadProject();
|
||||
private async _onProjectClosed(): Promise<void> {
|
||||
await this.unloadProject();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -360,6 +361,24 @@ export class AssetRegistryService implements IService {
|
||||
|
||||
try {
|
||||
const { listen } = await import('@tauri-apps/api/event');
|
||||
const { invoke } = await import('@tauri-apps/api/core');
|
||||
|
||||
// Start asset watcher for managed directories (assets, scenes)
|
||||
// 启动资产监视器监听托管目录(assets, scenes)
|
||||
// Note: scripts is watched by UserCodeService
|
||||
// 注意:scripts 目录由 UserCodeService 监听
|
||||
const directoriesToWatch = MANAGED_ASSET_DIRECTORIES.filter(dir => dir !== 'scripts');
|
||||
if (this._projectPath && directoriesToWatch.length > 0) {
|
||||
try {
|
||||
await invoke('watch_assets', {
|
||||
projectPath: this._projectPath,
|
||||
directories: directoriesToWatch
|
||||
});
|
||||
logger.info(`Started watching asset directories | 已启动资产目录监听: ${directoriesToWatch.join(', ')}`);
|
||||
} catch (watchError) {
|
||||
logger.warn('Failed to start asset watcher | 启动资产监视器失败:', watchError);
|
||||
}
|
||||
}
|
||||
|
||||
// Listen to user-code:file-changed event
|
||||
// 监听 user-code:file-changed 事件
|
||||
@@ -378,6 +397,10 @@ export class AssetRegistryService implements IService {
|
||||
// Skip .meta files
|
||||
if (absolutePath.endsWith('.meta')) continue;
|
||||
|
||||
// Only process files in managed directories
|
||||
// 只处理托管目录中的文件
|
||||
if (!this.isPathManaged(absolutePath)) continue;
|
||||
|
||||
// Register or refresh the asset
|
||||
await this.registerAsset(absolutePath);
|
||||
}
|
||||
@@ -386,6 +409,10 @@ export class AssetRegistryService implements IService {
|
||||
// Skip .meta files
|
||||
if (absolutePath.endsWith('.meta')) continue;
|
||||
|
||||
// Only process files in managed directories
|
||||
// 只处理托管目录中的文件
|
||||
if (!this.isPathManaged(absolutePath)) continue;
|
||||
|
||||
// Unregister the asset
|
||||
await this.unregisterAsset(absolutePath);
|
||||
}
|
||||
@@ -402,21 +429,37 @@ export class AssetRegistryService implements IService {
|
||||
* Unsubscribe from file change events
|
||||
* 取消订阅文件变化事件
|
||||
*/
|
||||
private _unsubscribeFromFileChanges(): void {
|
||||
private async _unsubscribeFromFileChanges(): Promise<void> {
|
||||
if (this._eventUnlisten) {
|
||||
this._eventUnlisten();
|
||||
this._eventUnlisten = undefined;
|
||||
logger.debug('Unsubscribed from file change events | 已取消订阅文件变化事件');
|
||||
}
|
||||
|
||||
// Stop the asset watcher | 停止资产监视器
|
||||
if (PlatformDetector.isTauriEnvironment() && this._projectPath) {
|
||||
try {
|
||||
const { invoke } = await import('@tauri-apps/api/core');
|
||||
// Stop watcher using the same key format used in watch_assets
|
||||
// 使用与 watch_assets 相同的键格式停止监视器
|
||||
await invoke('stop_watch_scripts', {
|
||||
projectPath: `${this._projectPath}/assets`
|
||||
});
|
||||
logger.debug('Stopped asset watcher | 已停止资产监视器');
|
||||
} catch (error) {
|
||||
logger.warn('Failed to stop asset watcher | 停止资产监视器失败:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unload current project
|
||||
* 卸载当前项目
|
||||
*/
|
||||
unloadProject(): void {
|
||||
async unloadProject(): Promise<void> {
|
||||
// Unsubscribe from file change events
|
||||
// 取消订阅文件变化事件
|
||||
this._unsubscribeFromFileChanges();
|
||||
await this._unsubscribeFromFileChanges();
|
||||
|
||||
this._projectPath = null;
|
||||
this._manifest = null;
|
||||
@@ -750,6 +793,18 @@ export class AssetRegistryService implements IService {
|
||||
await this._saveManifest();
|
||||
|
||||
const metadata = this._database.getMetadataByPath(relativePath);
|
||||
|
||||
// Publish event to notify ContentBrowser and other listeners
|
||||
// 发布事件通知 ContentBrowser 和其他监听者
|
||||
if (metadata) {
|
||||
this._messageHub?.publish('assets:changed', {
|
||||
type: 'add',
|
||||
path: absolutePath,
|
||||
relativePath,
|
||||
guid: metadata.guid
|
||||
});
|
||||
}
|
||||
|
||||
return metadata?.guid ?? null;
|
||||
}
|
||||
|
||||
@@ -762,9 +817,19 @@ export class AssetRegistryService implements IService {
|
||||
|
||||
const metadata = this._database.getMetadataByPath(relativePath);
|
||||
if (metadata) {
|
||||
this._database.removeAsset(metadata.guid);
|
||||
const guid = metadata.guid;
|
||||
this._database.removeAsset(guid);
|
||||
delete this._manifest.assets[relativePath];
|
||||
await this._saveManifest();
|
||||
|
||||
// Publish event to notify ContentBrowser and other listeners
|
||||
// 发布事件通知 ContentBrowser 和其他监听者
|
||||
this._messageHub?.publish('assets:changed', {
|
||||
type: 'remove',
|
||||
path: absolutePath,
|
||||
relativePath,
|
||||
guid
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -778,6 +843,18 @@ export class AssetRegistryService implements IService {
|
||||
// Re-register the asset
|
||||
await this._registerAssetFile(absolutePath, relativePath);
|
||||
await this._saveManifest();
|
||||
|
||||
const metadata = this._database.getMetadataByPath(relativePath);
|
||||
if (metadata) {
|
||||
// Publish event to notify ContentBrowser and other listeners
|
||||
// 发布事件通知 ContentBrowser 和其他监听者
|
||||
this._messageHub?.publish('assets:changed', {
|
||||
type: 'modify',
|
||||
path: absolutePath,
|
||||
relativePath,
|
||||
guid: metadata.guid
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -871,10 +948,12 @@ export class AssetRegistryService implements IService {
|
||||
|
||||
/**
|
||||
* Dispose the service
|
||||
* 销毁服务
|
||||
*/
|
||||
dispose(): void {
|
||||
this._unsubscribeFromFileChanges();
|
||||
this.unloadProject();
|
||||
// Fire and forget async cleanup | 异步清理(不等待)
|
||||
void this._unsubscribeFromFileChanges();
|
||||
void this.unloadProject();
|
||||
this._initialized = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -220,6 +220,36 @@ export interface WebBuildConfig extends BuildConfig {
|
||||
*/
|
||||
generateAssetCatalog?: boolean;
|
||||
|
||||
/**
|
||||
* Asset file extensions to copy (glob patterns).
|
||||
* 要复制的资产文件扩展名(glob 模式)。
|
||||
*
|
||||
* If not provided, uses default extensions.
|
||||
* If provided by plugins via AssetLoaderFactory.getAllSupportedExtensions(),
|
||||
* includes all registered loader extensions.
|
||||
*
|
||||
* 如果未提供,使用默认扩展名。
|
||||
* 如果通过 AssetLoaderFactory.getAllSupportedExtensions() 由插件提供,
|
||||
* 则包含所有已注册加载器的扩展名。
|
||||
*
|
||||
* @example ['*.png', '*.jpg', '*.particle', '*.bt']
|
||||
*/
|
||||
assetExtensions?: string[];
|
||||
|
||||
/**
|
||||
* Asset extension to type mapping.
|
||||
* 资产扩展名到类型的映射。
|
||||
*
|
||||
* Used by asset catalog generation to determine asset types.
|
||||
* If not provided, uses default mapping.
|
||||
*
|
||||
* 用于资产目录生成以确定资产类型。
|
||||
* 如果未提供,使用默认映射。
|
||||
*
|
||||
* @example { 'png': 'texture', 'particle': 'particle' }
|
||||
*/
|
||||
assetTypeMap?: Record<string, string>;
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -729,21 +729,64 @@ ${userScriptImports}
|
||||
* Step 4: Copy asset files.
|
||||
* 步骤 4:复制资产文件。
|
||||
*/
|
||||
/**
|
||||
* Default asset extensions (base types only).
|
||||
* 默认资产扩展名(仅基础类型)。
|
||||
*
|
||||
* Plugin-specific extensions should be declared in module.json's assetExtensions field.
|
||||
* 插件特定的扩展名应在 module.json 的 assetExtensions 字段中声明。
|
||||
*/
|
||||
private static readonly DEFAULT_ASSET_EXTENSIONS = [
|
||||
// 图片 | Images
|
||||
'*.png', '*.jpg', '*.jpeg', '*.gif', '*.webp', '*.bmp', '*.svg',
|
||||
// 音频 | Audio
|
||||
'*.mp3', '*.ogg', '*.wav', '*.m4a', '*.aac', '*.flac',
|
||||
// 数据 | Data
|
||||
'*.json', '*.xml', '*.yaml', '*.yml',
|
||||
// 字体 | Fonts
|
||||
'*.ttf', '*.woff', '*.woff2', '*.otf',
|
||||
// 精灵和图集 | Sprites and Atlases
|
||||
'*.atlas', '*.fnt',
|
||||
// Note: Plugin-specific extensions (*.particle, *.btree, *.prefab, etc.)
|
||||
// are now declared in each module's module.json assetExtensions field.
|
||||
// 注意:插件特定的扩展名(*.particle, *.btree, *.prefab 等)
|
||||
// 现在在各模块的 module.json assetExtensions 字段中声明。
|
||||
];
|
||||
|
||||
private async _stepCopyAssets(context: BuildContext): Promise<void> {
|
||||
const fs = this._getFileSystem(context);
|
||||
const webConfig = context.config as WebBuildConfig;
|
||||
|
||||
const assetsDir = `${context.projectRoot}/assets`;
|
||||
const outputAssetsDir = `${context.outputDir}/assets`;
|
||||
|
||||
if (await fs.pathExists(assetsDir)) {
|
||||
const count = await fs.copyDirectory(assetsDir, outputAssetsDir, [
|
||||
'*.png', '*.jpg', '*.jpeg', '*.gif', '*.webp',
|
||||
'*.mp3', '*.ogg', '*.wav', '*.m4a',
|
||||
'*.json', '*.xml',
|
||||
'*.ttf', '*.woff', '*.woff2',
|
||||
'*.atlas', '*.fnt'
|
||||
]);
|
||||
console.log(`[WebBuild] Copied ${count} asset files`);
|
||||
// 优先级:1. 配置提供的扩展名 2. 模块声明的扩展名 3. 默认扩展名
|
||||
// Priority: 1. Config-provided extensions 2. Module-declared extensions 3. Default extensions
|
||||
let extensions: string[];
|
||||
|
||||
if (webConfig.assetExtensions && webConfig.assetExtensions.length > 0) {
|
||||
// 使用配置明确提供的扩展名
|
||||
// Use explicitly provided config extensions
|
||||
extensions = webConfig.assetExtensions;
|
||||
} else {
|
||||
// 从模块收集扩展名并与默认值合并
|
||||
// Collect from modules and merge with defaults
|
||||
const moduleExtensions = this._collectModuleAssetExtensions(context);
|
||||
const combinedPatterns = new Set([
|
||||
...WebBuildPipeline.DEFAULT_ASSET_EXTENSIONS,
|
||||
...moduleExtensions.patterns
|
||||
]);
|
||||
extensions = Array.from(combinedPatterns);
|
||||
|
||||
// 存储合并后的类型映射供后续步骤使用
|
||||
// Store merged type map for later steps
|
||||
context.data.set('moduleAssetTypeMap', moduleExtensions.typeMap);
|
||||
}
|
||||
|
||||
console.log(`[WebBuild] Using ${extensions.length} extension patterns: ${extensions.slice(0, 10).join(', ')}${extensions.length > 10 ? '...' : ''}`);
|
||||
const count = await fs.copyDirectory(assetsDir, outputAssetsDir, extensions);
|
||||
console.log(`[WebBuild] Copied ${count} asset files using ${extensions.length} extension patterns`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -798,6 +841,7 @@ ${userScriptImports}
|
||||
*/
|
||||
private async _stepGenerateCatalog(context: BuildContext): Promise<void> {
|
||||
const fs = this._getFileSystem(context);
|
||||
const webConfig = context.config as WebBuildConfig;
|
||||
|
||||
// Meta file format from asset-system-editor
|
||||
// 来自 asset-system-editor 的 meta 文件格式
|
||||
@@ -811,12 +855,14 @@ ${userScriptImports}
|
||||
}
|
||||
|
||||
// Use unified IAssetCatalog format from @esengine/asset-system
|
||||
// 使用 @esengine/asset-system 中统一的 IAssetCatalog 格式
|
||||
// 使用 @esengine/asset-system 中 IRuntimeCatalog 格式(运行时期望 assets 字段)
|
||||
// Use IRuntimeCatalog format from @esengine/asset-system (runtime expects assets field)
|
||||
const catalog = {
|
||||
version: '1.0.0',
|
||||
createdAt: Date.now(),
|
||||
loadStrategy: 'file' as const, // Web builds use file-based loading
|
||||
entries: {} as Record<string, {
|
||||
bundles: {} as Record<string, unknown>, // Required by IRuntimeCatalog
|
||||
assets: {} as Record<string, {
|
||||
guid: string;
|
||||
path: string;
|
||||
type: string;
|
||||
@@ -831,16 +877,25 @@ ${userScriptImports}
|
||||
const outputAssetsDir = `${context.outputDir}/assets`;
|
||||
const outputScenesDir = `${context.outputDir}/scenes`;
|
||||
|
||||
let totalMetaFiles = 0;
|
||||
let skippedNoGuid = 0;
|
||||
let skippedNoOutput = 0;
|
||||
let addedEntries = 0;
|
||||
|
||||
for (const dirName of sourceAssetDirs) {
|
||||
const sourceDir = `${context.projectRoot}/${dirName}`;
|
||||
if (!await fs.pathExists(sourceDir)) continue;
|
||||
|
||||
const metaFiles = await fs.listFilesByExtension(sourceDir, ['.meta'], true);
|
||||
const metaFiles = await fs.listFilesByExtension(sourceDir, ['meta'], true);
|
||||
totalMetaFiles += metaFiles.length;
|
||||
|
||||
for (const metaFile of metaFiles) {
|
||||
try {
|
||||
const meta = await fs.readJson<AssetMeta>(metaFile);
|
||||
if (!meta.guid) continue;
|
||||
if (!meta.guid) {
|
||||
skippedNoGuid++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const assetSourcePath = metaFile.replace(/\.meta$/, '');
|
||||
|
||||
@@ -861,29 +916,58 @@ ${userScriptImports}
|
||||
|
||||
relativePath = relativePath.replace(/\\/g, '/');
|
||||
|
||||
if (!await fs.pathExists(outputPath)) continue;
|
||||
if (!await fs.pathExists(outputPath)) {
|
||||
skippedNoOutput++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const size = await fs.getFileSize(outputPath);
|
||||
|
||||
catalog.entries[meta.guid] = {
|
||||
// 获取资产类型:
|
||||
// 1. 如果 meta.type 存在且不是 "custom",使用它
|
||||
// 2. 否则尝试从模块声明的扩展名推断
|
||||
// 3. 最后回退到 meta.type(可能是 "custom")
|
||||
// Get asset type:
|
||||
// 1. If meta.type exists and is not "custom", use it
|
||||
// 2. Otherwise try to infer from module-declared extensions
|
||||
// 3. Fall back to meta.type (may be "custom")
|
||||
let assetType = meta.type;
|
||||
const fileName = assetSourcePath.split(/[/\\]/).pop() || '';
|
||||
|
||||
// 如果类型是 "custom" 或空,尝试从模块扩展名推断
|
||||
// If type is "custom" or empty, try to infer from module extensions
|
||||
if (!assetType || assetType === 'custom') {
|
||||
const inferredType = this._getAssetTypeFromFileName(fileName, webConfig, context);
|
||||
// 只有推断出具体类型时才使用(不是 "data" 这种通用类型)
|
||||
// Only use inferred type if it's specific (not generic like "data")
|
||||
if (inferredType && inferredType !== 'data') {
|
||||
assetType = inferredType;
|
||||
} else if (!assetType) {
|
||||
assetType = inferredType || 'custom';
|
||||
}
|
||||
}
|
||||
|
||||
catalog.assets[meta.guid] = {
|
||||
guid: meta.guid,
|
||||
path: relativePath,
|
||||
type: meta.type || this._getAssetType(assetSourcePath.split('.').pop() || ''),
|
||||
type: assetType,
|
||||
size,
|
||||
hash: hashFileInfo(relativePath, size)
|
||||
};
|
||||
addedEntries++;
|
||||
} catch (error) {
|
||||
console.warn(`[WebBuild] Failed to process meta file: ${metaFile}`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
await fs.writeFile(
|
||||
`${context.outputDir}/asset-catalog.json`,
|
||||
JSON.stringify(catalog, null, 2)
|
||||
);
|
||||
|
||||
console.log(`[WebBuild] Generated asset catalog: ${Object.keys(catalog.entries).length} assets, strategy=${catalog.loadStrategy}`);
|
||||
console.log(`[WebBuild] Generated asset catalog: ${Object.keys(catalog.assets).length} assets, strategy=${catalog.loadStrategy}`);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -901,7 +985,7 @@ ${userScriptImports}
|
||||
let mainScenePath = './scenes/main.ecs';
|
||||
const scenesDir = `${context.outputDir}/scenes`;
|
||||
if (await fs.pathExists(scenesDir)) {
|
||||
const sceneFiles = await fs.listFilesByExtension(scenesDir, ['.ecs', '.scene']);
|
||||
const sceneFiles = await fs.listFilesByExtension(scenesDir, ['ecs', 'scene']);
|
||||
if (sceneFiles.length > 0) {
|
||||
const sceneName = sceneFiles[0].split(/[/\\]/).pop();
|
||||
mainScenePath = `./scenes/${sceneName}`;
|
||||
@@ -1755,15 +1839,156 @@ ${pluginLoads}
|
||||
</html>`;
|
||||
}
|
||||
|
||||
private _getAssetType(ext: string): string {
|
||||
const typeMap: Record<string, string> = {
|
||||
'png': 'texture', 'jpg': 'texture', 'jpeg': 'texture', 'gif': 'texture', 'webp': 'texture',
|
||||
'mp3': 'audio', 'ogg': 'audio', 'wav': 'audio', 'm4a': 'audio',
|
||||
'json': 'data', 'xml': 'data',
|
||||
'ttf': 'font', 'woff': 'font', 'woff2': 'font',
|
||||
'atlas': 'atlas', 'fnt': 'font'
|
||||
};
|
||||
return typeMap[ext] || 'binary';
|
||||
/**
|
||||
* Default extension to type mapping (base types only).
|
||||
* 默认扩展名到类型的映射(仅基础类型)。
|
||||
*
|
||||
* Plugin-specific mappings should be declared in module.json's assetExtensions field.
|
||||
* 插件特定的映射应在 module.json 的 assetExtensions 字段中声明。
|
||||
*/
|
||||
private static readonly DEFAULT_ASSET_TYPE_MAP: Record<string, string> = {
|
||||
// 图片 | Images
|
||||
'png': 'texture', 'jpg': 'texture', 'jpeg': 'texture', 'gif': 'texture',
|
||||
'webp': 'texture', 'bmp': 'texture', 'svg': 'texture',
|
||||
// 音频 | Audio
|
||||
'mp3': 'audio', 'ogg': 'audio', 'wav': 'audio', 'm4a': 'audio',
|
||||
'aac': 'audio', 'flac': 'audio',
|
||||
// 数据 | Data
|
||||
'json': 'data', 'xml': 'data', 'yaml': 'data', 'yml': 'data',
|
||||
// 字体 | Fonts
|
||||
'ttf': 'font', 'woff': 'font', 'woff2': 'font', 'otf': 'font', 'fnt': 'font',
|
||||
// 精灵图集 | Sprite Atlas
|
||||
'atlas': 'atlas',
|
||||
// Note: Plugin-specific types (particle, behavior-tree, prefab, tilemap, etc.)
|
||||
// are now declared in each module's module.json assetExtensions field.
|
||||
// 注意:插件特定的类型(particle, behavior-tree, prefab, tilemap 等)
|
||||
// 现在在各模块的 module.json assetExtensions 字段中声明。
|
||||
};
|
||||
|
||||
/**
|
||||
* Cached type map from config (merged with defaults).
|
||||
* 从配置缓存的类型映射(与默认值合并)。
|
||||
*/
|
||||
private _assetTypeMap: Record<string, string> | null = null;
|
||||
|
||||
/**
|
||||
* Get asset type by extension.
|
||||
* 根据扩展名获取资产类型。
|
||||
*
|
||||
* Priority: config.assetTypeMap > moduleAssetTypeMap > DEFAULT_ASSET_TYPE_MAP
|
||||
* 优先级:配置类型映射 > 模块类型映射 > 默认类型映射
|
||||
*/
|
||||
private _getAssetType(ext: string, config?: WebBuildConfig, context?: BuildContext): string {
|
||||
const lowerExt = ext.toLowerCase();
|
||||
|
||||
// 1. 配置提供的类型映射
|
||||
// 1. Config-provided type map
|
||||
if (config?.assetTypeMap) {
|
||||
if (!this._assetTypeMap) {
|
||||
// 合并默认值和配置
|
||||
// Merge defaults and config
|
||||
const moduleTypeMap = context?.data.get('moduleAssetTypeMap') as Record<string, string> || {};
|
||||
this._assetTypeMap = {
|
||||
...WebBuildPipeline.DEFAULT_ASSET_TYPE_MAP,
|
||||
...moduleTypeMap,
|
||||
...config.assetTypeMap
|
||||
};
|
||||
}
|
||||
return this._assetTypeMap[lowerExt] || 'binary';
|
||||
}
|
||||
|
||||
// 2. 模块声明的类型映射
|
||||
// 2. Module-declared type map
|
||||
if (context) {
|
||||
const moduleTypeMap = context.data.get('moduleAssetTypeMap') as Record<string, string> | undefined;
|
||||
if (moduleTypeMap && moduleTypeMap[lowerExt]) {
|
||||
return moduleTypeMap[lowerExt];
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 默认类型映射
|
||||
// 3. Default type map
|
||||
return WebBuildPipeline.DEFAULT_ASSET_TYPE_MAP[lowerExt] || 'binary';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get asset type from full filename, supporting compound extensions.
|
||||
* 从完整文件名获取资产类型,支持复合扩展名。
|
||||
*
|
||||
* @param fileName - File name (e.g., "explosion.particle.json")
|
||||
* @param config - Build config
|
||||
* @param context - Build context
|
||||
* @returns Asset type string
|
||||
*/
|
||||
private _getAssetTypeFromFileName(fileName: string, config?: WebBuildConfig, context?: BuildContext): string {
|
||||
const lowerName = fileName.toLowerCase();
|
||||
|
||||
// 检查模块声明的复合扩展名
|
||||
// Check module-declared compound extensions
|
||||
const moduleTypeMap = context?.data.get('moduleAssetTypeMap') as Record<string, string> | undefined;
|
||||
if (moduleTypeMap) {
|
||||
// 优先检查复合扩展名(按长度排序,最长优先)
|
||||
// Prioritize compound extensions (sorted by length, longest first)
|
||||
const sortedExts = Object.keys(moduleTypeMap).sort((a, b) => b.length - a.length);
|
||||
for (const ext of sortedExts) {
|
||||
if (lowerName.endsWith(ext)) {
|
||||
return moduleTypeMap[ext];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 回退到简单扩展名
|
||||
// Fallback to simple extension
|
||||
const simpleExt = fileName.split('.').pop() || '';
|
||||
return this._getAssetType(simpleExt, config, context);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert glob patterns to plain extension list for listFilesByExtension.
|
||||
* 将 glob 模式转换为 listFilesByExtension 所需的简单扩展名列表。
|
||||
*
|
||||
* @param patterns - Glob patterns like ['*.png', '*.jpg']
|
||||
* @returns Plain extensions like ['png', 'jpg']
|
||||
*/
|
||||
private _globPatternsToExtensions(patterns: string[]): string[] {
|
||||
return patterns
|
||||
.map(p => {
|
||||
// Remove *. prefix if present
|
||||
const match = p.match(/^\*\.(.+)$/);
|
||||
return match ? match[1].toLowerCase() : p.toLowerCase();
|
||||
})
|
||||
.filter(ext => ext.length > 0 && !ext.includes('*'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect asset extensions from all enabled modules.
|
||||
* 从所有启用的模块收集资产扩展名。
|
||||
*
|
||||
* @param context - Build context containing module list
|
||||
* @returns Glob patterns and type map
|
||||
*/
|
||||
private _collectModuleAssetExtensions(context: BuildContext): {
|
||||
patterns: string[];
|
||||
typeMap: Record<string, string>;
|
||||
} {
|
||||
const allModules = context.data.get('allModules') as ModuleManifest[] || [];
|
||||
const patterns: string[] = [];
|
||||
const typeMap: Record<string, string> = {};
|
||||
|
||||
for (const module of allModules) {
|
||||
if (module.assetExtensions) {
|
||||
for (const [ext, type] of Object.entries(module.assetExtensions)) {
|
||||
// 转换为 glob 模式 | Convert to glob pattern
|
||||
const cleanExt = ext.startsWith('.') ? ext.substring(1) : ext;
|
||||
patterns.push(`*.${cleanExt}`);
|
||||
|
||||
// 添加类型映射 | Add type mapping
|
||||
typeMap[cleanExt.toLowerCase()] = type;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { patterns, typeMap };
|
||||
}
|
||||
|
||||
private _getStatusForStep(stepId: string): BuildStatus {
|
||||
@@ -2044,27 +2269,44 @@ ${userScriptImports}
|
||||
const assetsDir = `${context.projectRoot}/assets`;
|
||||
if (await fs.pathExists(assetsDir)) {
|
||||
console.log('[WebBuild] Inlining assets...');
|
||||
const assetExtensions = ['png', 'jpg', 'jpeg', 'gif', 'webp', 'mp3', 'ogg', 'wav', 'ttf', 'woff', 'woff2', 'json'];
|
||||
|
||||
// 优先级:1. 配置提供的扩展名 2. 模块声明的扩展名 3. 默认扩展名
|
||||
// Priority: 1. Config-provided extensions 2. Module-declared extensions 3. Default extensions
|
||||
let extensionPatterns: string[];
|
||||
if (webConfig.assetExtensions && webConfig.assetExtensions.length > 0) {
|
||||
extensionPatterns = webConfig.assetExtensions;
|
||||
} else {
|
||||
const moduleExtensions = this._collectModuleAssetExtensions(context);
|
||||
const combinedPatterns = new Set([
|
||||
...WebBuildPipeline.DEFAULT_ASSET_EXTENSIONS,
|
||||
...moduleExtensions.patterns
|
||||
]);
|
||||
extensionPatterns = Array.from(combinedPatterns);
|
||||
}
|
||||
|
||||
const assetExtensions = this._globPatternsToExtensions(extensionPatterns);
|
||||
const assetFiles = await fs.listFilesByExtension(assetsDir, assetExtensions, true);
|
||||
|
||||
for (const assetPath of assetFiles) {
|
||||
const ext = assetPath.split('.').pop()?.toLowerCase() || '';
|
||||
const fileName = assetPath.split(/[/\\]/).pop() || '';
|
||||
const ext = fileName.split('.').pop()?.toLowerCase() || '';
|
||||
const relativePath = assetPath.replace(assetsDir, '').replace(/\\/g, '/').replace(/^\//, '');
|
||||
const mimeType = this._getMimeType(ext);
|
||||
|
||||
if (ext === 'json') {
|
||||
// JSON files are read as text
|
||||
// JSON files - check for compound extension types
|
||||
const content = await fs.readFile(assetPath);
|
||||
const assetType = this._getAssetTypeFromFileName(fileName, webConfig, context);
|
||||
assetData[relativePath] = {
|
||||
dataUrl: `data:application/json;base64,${Buffer.from(content).toString('base64')}`,
|
||||
type: 'data'
|
||||
type: assetType
|
||||
};
|
||||
} else {
|
||||
// Binary files
|
||||
const base64 = await fs.readBinaryFileAsBase64(assetPath);
|
||||
assetData[relativePath] = {
|
||||
dataUrl: `data:${mimeType};base64,${base64}`,
|
||||
type: this._getAssetType(ext)
|
||||
type: this._getAssetTypeFromFileName(fileName, webConfig, context)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -165,6 +165,33 @@ export class CommandManager {
|
||||
const batchCommand = new BatchCommand(commands);
|
||||
this.execute(batchCommand);
|
||||
}
|
||||
|
||||
/**
|
||||
* 将命令推入撤销栈但不执行
|
||||
* Push command to undo stack without executing
|
||||
*
|
||||
* 用于已经执行过的操作(如拖动变换),只需要记录到历史
|
||||
* Used for operations that have already been performed (like drag transforms),
|
||||
* only need to record to history
|
||||
*/
|
||||
pushWithoutExecute(command: ICommand): void {
|
||||
if (this.config.autoMerge && this.undoStack.length > 0) {
|
||||
const lastCommand = this.undoStack[this.undoStack.length - 1];
|
||||
if (lastCommand && lastCommand.canMergeWith(command)) {
|
||||
const mergedCommand = lastCommand.mergeWith(command);
|
||||
this.undoStack[this.undoStack.length - 1] = mergedCommand;
|
||||
this.redoStack = [];
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
this.undoStack.push(command);
|
||||
this.redoStack = [];
|
||||
|
||||
if (this.undoStack.length > this.config.maxHistorySize) {
|
||||
this.undoStack.shift();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Injectable, IService, Entity, Core, HierarchyComponent } from '@esengine/ecs-framework';
|
||||
import { Injectable, IService, Entity, Core, HierarchyComponent, createLogger } from '@esengine/ecs-framework';
|
||||
import { MessageHub } from './MessageHub';
|
||||
|
||||
const logger = createLogger('EntityStoreService');
|
||||
|
||||
export interface EntityTreeNode {
|
||||
entity: Entity;
|
||||
children: EntityTreeNode[];
|
||||
@@ -85,12 +87,17 @@ export class EntityStoreService implements IService {
|
||||
|
||||
public syncFromScene(): void {
|
||||
const scene = Core.scene;
|
||||
if (!scene) return;
|
||||
if (!scene) {
|
||||
logger.warn('syncFromScene called but no scene available');
|
||||
return;
|
||||
}
|
||||
|
||||
this.entities.clear();
|
||||
this.rootEntityIds = [];
|
||||
|
||||
let entityCount = 0;
|
||||
scene.entities.forEach((entity) => {
|
||||
entityCount++;
|
||||
this.entities.set(entity.id, entity);
|
||||
const hierarchy = entity.getComponent(HierarchyComponent);
|
||||
const bHasNoParent = hierarchy?.parentId === null || hierarchy?.parentId === undefined;
|
||||
@@ -98,6 +105,14 @@ export class EntityStoreService implements IService {
|
||||
this.rootEntityIds.push(entity.id);
|
||||
}
|
||||
});
|
||||
|
||||
logger.debug(`syncFromScene: synced ${entityCount} entities, ${this.rootEntityIds.length} root entities`);
|
||||
if (this.rootEntityIds.length > 0) {
|
||||
const rootNames = this.rootEntityIds
|
||||
.map(id => this.entities.get(id)?.name)
|
||||
.join(', ');
|
||||
logger.debug(`Root entities: ${rootNames}`);
|
||||
}
|
||||
}
|
||||
|
||||
public reorderEntity(entityId: number, newIndex: number): void {
|
||||
|
||||
476
packages/editor-core/src/Services/PrefabService.ts
Normal file
476
packages/editor-core/src/Services/PrefabService.ts
Normal file
@@ -0,0 +1,476 @@
|
||||
/**
|
||||
* 预制体服务
|
||||
* Prefab service
|
||||
*
|
||||
* 提供预制体实例管理功能:应用修改到源预制体、还原实例、断开链接等。
|
||||
* Provides prefab instance management: apply to source, revert instance, break link, etc.
|
||||
*/
|
||||
|
||||
import { Injectable, IService, Entity, Core, createLogger } from '@esengine/ecs-framework';
|
||||
import { PrefabInstanceComponent, PrefabSerializer, PrefabData, ComponentRegistry, ComponentType, HierarchySystem } from '@esengine/ecs-framework';
|
||||
import { MessageHub } from './MessageHub';
|
||||
|
||||
const logger = createLogger('PrefabService');
|
||||
|
||||
/**
|
||||
* 预制体属性覆盖信息
|
||||
* Prefab property override info
|
||||
*/
|
||||
export interface PrefabPropertyOverride {
|
||||
/** 组件类型名称 | Component type name */
|
||||
componentType: string;
|
||||
/** 属性路径 | Property path */
|
||||
propertyPath: string;
|
||||
/** 当前值 | Current value */
|
||||
currentValue: unknown;
|
||||
/** 原始值(来自源预制体)| Original value (from source prefab) */
|
||||
originalValue?: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* 文件 API 接口(用于依赖注入)
|
||||
* File API interface (for dependency injection)
|
||||
*/
|
||||
export interface IPrefabFileAPI {
|
||||
readFileContent(path: string): Promise<string>;
|
||||
writeFileContent(path: string, content: string): Promise<void>;
|
||||
pathExists(path: string): Promise<boolean>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 预制体服务
|
||||
* Prefab service
|
||||
*
|
||||
* 提供预制体实例的管理功能。
|
||||
* Provides prefab instance management functionality.
|
||||
*/
|
||||
@Injectable()
|
||||
export class PrefabService implements IService {
|
||||
private fileAPI: IPrefabFileAPI | null = null;
|
||||
|
||||
constructor(private messageHub: MessageHub) {}
|
||||
|
||||
/**
|
||||
* 设置文件 API
|
||||
* Set file API
|
||||
*
|
||||
* @param fileAPI - 文件 API 实例 | File API instance
|
||||
*/
|
||||
public setFileAPI(fileAPI: IPrefabFileAPI): void {
|
||||
this.fileAPI = fileAPI;
|
||||
}
|
||||
|
||||
public dispose(): void {
|
||||
this.fileAPI = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查实体是否为预制体实例
|
||||
* Check if entity is a prefab instance
|
||||
*
|
||||
* @param entity - 要检查的实体 | Entity to check
|
||||
* @returns 是否为预制体实例 | Whether it's a prefab instance
|
||||
*/
|
||||
public isPrefabInstance(entity: Entity): boolean {
|
||||
return PrefabSerializer.isPrefabInstance(entity);
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查实体是否为预制体实例的根节点
|
||||
* Check if entity is the root of a prefab instance
|
||||
*
|
||||
* @param entity - 要检查的实体 | Entity to check
|
||||
* @returns 是否为根节点 | Whether it's the root
|
||||
*/
|
||||
public isPrefabInstanceRoot(entity: Entity): boolean {
|
||||
const comp = entity.getComponent(PrefabInstanceComponent);
|
||||
return comp?.isRoot ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取预制体实例组件
|
||||
* Get prefab instance component
|
||||
*
|
||||
* @param entity - 实体 | Entity
|
||||
* @returns 预制体实例组件,如果不是实例则返回 null | Component or null if not an instance
|
||||
*/
|
||||
public getPrefabInstanceComponent(entity: Entity): PrefabInstanceComponent | null {
|
||||
return entity.getComponent(PrefabInstanceComponent) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取预制体实例的根实体
|
||||
* Get root entity of prefab instance
|
||||
*
|
||||
* @param entity - 预制体实例中的任意实体 | Any entity in the prefab instance
|
||||
* @returns 根实体,如果不是实例则返回 null | Root entity or null
|
||||
*/
|
||||
public getPrefabInstanceRoot(entity: Entity): Entity | null {
|
||||
return PrefabSerializer.getPrefabInstanceRoot(entity);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取实例相对于源预制体的所有属性覆盖
|
||||
* Get all property overrides of instance relative to source prefab
|
||||
*
|
||||
* @param entity - 预制体实例 | Prefab instance
|
||||
* @returns 属性覆盖列表 | List of property overrides
|
||||
*/
|
||||
public getOverrides(entity: Entity): PrefabPropertyOverride[] {
|
||||
const comp = entity.getComponent(PrefabInstanceComponent);
|
||||
if (!comp) return [];
|
||||
|
||||
const overrides: PrefabPropertyOverride[] = [];
|
||||
for (const key of comp.modifiedProperties) {
|
||||
const [componentType, ...pathParts] = key.split('.');
|
||||
const propertyPath = pathParts.join('.');
|
||||
|
||||
// 获取当前值 | Get current value
|
||||
let currentValue: unknown = undefined;
|
||||
for (const compInstance of entity.components) {
|
||||
const typeName = (compInstance.constructor as any).__componentTypeName || compInstance.constructor.name;
|
||||
if (typeName === componentType) {
|
||||
currentValue = this.getNestedValue(compInstance, propertyPath);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 获取原始值 | Get original value
|
||||
const originalValue = comp.getOriginalValue?.(key);
|
||||
|
||||
overrides.push({
|
||||
componentType,
|
||||
propertyPath,
|
||||
currentValue,
|
||||
originalValue
|
||||
});
|
||||
}
|
||||
|
||||
return overrides;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查实例是否有修改
|
||||
* Check if instance has modifications
|
||||
*
|
||||
* @param entity - 预制体实例 | Prefab instance
|
||||
* @returns 是否有修改 | Whether it has modifications
|
||||
*/
|
||||
public hasModifications(entity: Entity): boolean {
|
||||
const comp = entity.getComponent(PrefabInstanceComponent);
|
||||
return comp ? comp.modifiedProperties.length > 0 : false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取实例的修改数量
|
||||
* Get modification count of instance
|
||||
*
|
||||
* @param entity - 预制体实例 | Prefab instance
|
||||
* @returns 修改数量 | Number of modifications
|
||||
*/
|
||||
public getModificationCount(entity: Entity): number {
|
||||
const comp = entity.getComponent(PrefabInstanceComponent);
|
||||
return comp?.modifiedProperties.length ?? 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将实例的修改应用到源预制体
|
||||
* Apply instance modifications to source prefab
|
||||
*
|
||||
* @param entity - 预制体实例(必须是根节点)| Prefab instance (must be root)
|
||||
* @returns 是否成功应用 | Whether application was successful
|
||||
*/
|
||||
public async applyToPrefab(entity: Entity): Promise<boolean> {
|
||||
if (!this.fileAPI) {
|
||||
logger.error('File API not set, cannot apply to prefab');
|
||||
return false;
|
||||
}
|
||||
|
||||
const comp = entity.getComponent(PrefabInstanceComponent);
|
||||
if (!comp) {
|
||||
logger.warn('Entity is not a prefab instance');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!comp.isRoot) {
|
||||
logger.warn('Can only apply from root prefab instance');
|
||||
return false;
|
||||
}
|
||||
|
||||
const prefabPath = comp.sourcePrefabPath;
|
||||
if (!prefabPath) {
|
||||
logger.warn('Source prefab path not found');
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
// 检查源文件是否存在 | Check if source file exists
|
||||
const exists = await this.fileAPI.pathExists(prefabPath);
|
||||
if (!exists) {
|
||||
logger.error(`Source prefab file not found: ${prefabPath}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
// 读取原始预制体以获取 GUID | Read original prefab to get GUID
|
||||
const originalContent = await this.fileAPI.readFileContent(prefabPath);
|
||||
const originalPrefabData = PrefabSerializer.deserialize(originalContent);
|
||||
const originalGuid = originalPrefabData.metadata.guid;
|
||||
|
||||
// 获取层级系统 | Get hierarchy system
|
||||
const scene = Core.scene;
|
||||
const hierarchySystem = scene?.getSystem(HierarchySystem) ?? undefined;
|
||||
|
||||
// 从当前实例创建新的预制体数据 | Create new prefab data from current instance
|
||||
const newPrefabData = PrefabSerializer.createPrefab(
|
||||
entity,
|
||||
{
|
||||
name: originalPrefabData.metadata.name,
|
||||
description: originalPrefabData.metadata.description,
|
||||
tags: originalPrefabData.metadata.tags,
|
||||
includeChildren: true
|
||||
},
|
||||
hierarchySystem
|
||||
);
|
||||
|
||||
// 保留原有 GUID 并更新修改时间 | Preserve original GUID and update modified time
|
||||
newPrefabData.metadata.guid = originalGuid;
|
||||
newPrefabData.metadata.createdAt = originalPrefabData.metadata.createdAt;
|
||||
newPrefabData.metadata.modifiedAt = Date.now();
|
||||
|
||||
// 序列化并保存 | Serialize and save
|
||||
const json = PrefabSerializer.serialize(newPrefabData, true);
|
||||
await this.fileAPI.writeFileContent(prefabPath, json);
|
||||
|
||||
// 清除修改记录 | Clear modification records
|
||||
comp.clearAllModifications();
|
||||
|
||||
logger.info(`Applied changes to prefab: ${prefabPath}`);
|
||||
|
||||
// 发布事件 | Publish event
|
||||
await this.messageHub.publish('prefab:applied', {
|
||||
entityId: entity.id,
|
||||
prefabPath,
|
||||
prefabGuid: originalGuid
|
||||
});
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error('Failed to apply to prefab:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将实例还原为源预制体的状态
|
||||
* Revert instance to source prefab state
|
||||
*
|
||||
* @param entity - 预制体实例(必须是根节点)| Prefab instance (must be root)
|
||||
* @returns 是否成功还原 | Whether revert was successful
|
||||
*/
|
||||
public async revertInstance(entity: Entity): Promise<boolean> {
|
||||
if (!this.fileAPI) {
|
||||
logger.error('File API not set, cannot revert instance');
|
||||
return false;
|
||||
}
|
||||
|
||||
const comp = entity.getComponent(PrefabInstanceComponent);
|
||||
if (!comp) {
|
||||
logger.warn('Entity is not a prefab instance');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!comp.isRoot) {
|
||||
logger.warn('Can only revert root prefab instance');
|
||||
return false;
|
||||
}
|
||||
|
||||
const prefabPath = comp.sourcePrefabPath;
|
||||
if (!prefabPath) {
|
||||
logger.warn('Source prefab path not found');
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
// 读取源预制体 | Read source prefab
|
||||
const content = await this.fileAPI.readFileContent(prefabPath);
|
||||
const prefabData = PrefabSerializer.deserialize(content);
|
||||
|
||||
// 还原所有修改的属性 | Revert all modified properties
|
||||
for (const key of [...comp.modifiedProperties]) {
|
||||
const [componentType, ...pathParts] = key.split('.');
|
||||
const propertyPath = pathParts.join('.');
|
||||
|
||||
// 从 originalValues 获取原始值 | Get original value from originalValues
|
||||
const originalValue = comp.getOriginalValue?.(key);
|
||||
if (originalValue !== undefined) {
|
||||
// 应用原始值到组件 | Apply original value to component
|
||||
for (const compInstance of entity.components) {
|
||||
const typeName = (compInstance.constructor as any).__componentTypeName || compInstance.constructor.name;
|
||||
if (typeName === componentType) {
|
||||
this.setNestedValue(compInstance, propertyPath, originalValue);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 清除修改记录 | Clear modification records
|
||||
comp.clearAllModifications();
|
||||
|
||||
logger.info(`Reverted prefab instance: ${entity.name}`);
|
||||
|
||||
// 发布事件 | Publish event
|
||||
await this.messageHub.publish('prefab:reverted', {
|
||||
entityId: entity.id,
|
||||
prefabPath,
|
||||
prefabGuid: comp.sourcePrefabGuid
|
||||
});
|
||||
|
||||
// 发布组件变更事件以刷新 UI | Publish component change event to refresh UI
|
||||
await this.messageHub.publish('component:property:changed', {
|
||||
entityId: entity.id
|
||||
});
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error('Failed to revert instance:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 还原单个属性到源预制体的值
|
||||
* Revert single property to source prefab value
|
||||
*
|
||||
* @param entity - 预制体实例 | Prefab instance
|
||||
* @param componentType - 组件类型名称 | Component type name
|
||||
* @param propertyPath - 属性路径 | Property path
|
||||
* @returns 是否成功还原 | Whether revert was successful
|
||||
*/
|
||||
public async revertProperty(entity: Entity, componentType: string, propertyPath: string): Promise<boolean> {
|
||||
const comp = entity.getComponent(PrefabInstanceComponent);
|
||||
if (!comp) {
|
||||
logger.warn('Entity is not a prefab instance');
|
||||
return false;
|
||||
}
|
||||
|
||||
const key = `${componentType}.${propertyPath}`;
|
||||
|
||||
// 从 originalValues 获取原始值 | Get original value from originalValues
|
||||
const originalValue = comp.getOriginalValue?.(key);
|
||||
if (originalValue === undefined) {
|
||||
logger.warn(`No original value found for ${key}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
// 应用原始值到组件 | Apply original value to component
|
||||
for (const compInstance of entity.components) {
|
||||
const typeName = (compInstance.constructor as any).__componentTypeName || compInstance.constructor.name;
|
||||
if (typeName === componentType) {
|
||||
this.setNestedValue(compInstance, propertyPath, originalValue);
|
||||
|
||||
// 清除该属性的修改标记 | Clear modification mark for this property
|
||||
comp.clearPropertyModified(componentType, propertyPath);
|
||||
|
||||
logger.debug(`Reverted property ${key} to original value`);
|
||||
|
||||
// 发布事件 | Publish event
|
||||
await this.messageHub.publish('prefab:property:reverted', {
|
||||
entityId: entity.id,
|
||||
componentType,
|
||||
propertyPath
|
||||
});
|
||||
|
||||
// 发布组件变更事件以刷新 UI | Publish component change event to refresh UI
|
||||
await this.messageHub.publish('component:property:changed', {
|
||||
entityId: entity.id,
|
||||
componentType,
|
||||
propertyPath
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
logger.warn(`Component ${componentType} not found on entity`);
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 断开预制体链接
|
||||
* Break prefab link
|
||||
*
|
||||
* 移除实体的预制体实例组件,使其成为普通实体。
|
||||
* Removes the prefab instance component, making it a regular entity.
|
||||
*
|
||||
* @param entity - 预制体实例 | Prefab instance
|
||||
*/
|
||||
public breakPrefabLink(entity: Entity): void {
|
||||
const comp = entity.getComponent(PrefabInstanceComponent);
|
||||
if (!comp) {
|
||||
logger.warn('Entity is not a prefab instance');
|
||||
return;
|
||||
}
|
||||
|
||||
const wasRoot = comp.isRoot;
|
||||
const prefabGuid = comp.sourcePrefabGuid;
|
||||
const prefabPath = comp.sourcePrefabPath;
|
||||
|
||||
// 移除预制体实例组件 | Remove prefab instance component
|
||||
entity.removeComponentByType(PrefabInstanceComponent);
|
||||
|
||||
// 如果是根节点,也要移除所有子实体的预制体实例组件
|
||||
// If it's root, also remove prefab instance components from all children
|
||||
if (wasRoot) {
|
||||
const scene = Core.scene;
|
||||
if (scene) {
|
||||
scene.entities.forEach((e) => {
|
||||
const childComp = e.getComponent(PrefabInstanceComponent);
|
||||
if (childComp && childComp.rootInstanceEntityId === entity.id) {
|
||||
e.removeComponentByType(PrefabInstanceComponent);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`Broke prefab link for entity: ${entity.name}`);
|
||||
|
||||
// 发布事件 | Publish event
|
||||
this.messageHub.publish('prefab:link:broken', {
|
||||
entityId: entity.id,
|
||||
wasRoot,
|
||||
prefabGuid,
|
||||
prefabPath
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取嵌套属性值
|
||||
* Get nested property value
|
||||
*/
|
||||
private getNestedValue(obj: any, path: string): unknown {
|
||||
const parts = path.split('.');
|
||||
let current = obj;
|
||||
for (const part of parts) {
|
||||
if (current === null || current === undefined) return undefined;
|
||||
current = current[part];
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置嵌套属性值
|
||||
* Set nested property value
|
||||
*/
|
||||
private setNestedValue(obj: any, path: string, value: unknown): void {
|
||||
const parts = path.split('.');
|
||||
let current = obj;
|
||||
for (let i = 0; i < parts.length - 1; i++) {
|
||||
if (current[parts[i]] === null || current[parts[i]] === undefined) {
|
||||
current[parts[i]] = {};
|
||||
}
|
||||
current = current[parts[i]];
|
||||
}
|
||||
current[parts[parts.length - 1]] = value;
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import type { IService } from '@esengine/ecs-framework';
|
||||
import { Injectable } from '@esengine/ecs-framework';
|
||||
import { createLogger, Scene } from '@esengine/ecs-framework';
|
||||
import { MessageHub } from './MessageHub';
|
||||
import { SceneTemplateRegistry } from './SceneTemplateRegistry';
|
||||
import type { IFileAPI } from '../Types/IFileAPI';
|
||||
|
||||
const logger = createLogger('ProjectService');
|
||||
@@ -137,8 +138,13 @@ export class ProjectService implements IService {
|
||||
await this.fileAPI.createDirectory(scenesPath);
|
||||
|
||||
const defaultScenePath = `${scenesPath}${sep}${config.defaultScene}`;
|
||||
const emptyScene = new Scene();
|
||||
const sceneData = emptyScene.serialize({
|
||||
const defaultScene = new Scene();
|
||||
|
||||
// 使用场景模板注册表创建默认实体(如相机)
|
||||
// Use scene template registry to create default entities (e.g., camera)
|
||||
SceneTemplateRegistry.createDefaultEntities(defaultScene);
|
||||
|
||||
const sceneData = defaultScene.serialize({
|
||||
format: 'json',
|
||||
pretty: true,
|
||||
includeMetadata: true
|
||||
|
||||
@@ -1,20 +1,11 @@
|
||||
import type { IService, PropertyOptions, PropertyAction, PropertyControl, AssetType, EnumOption } from '@esengine/ecs-framework';
|
||||
import { Injectable, Component, getPropertyMetadata } from '@esengine/ecs-framework';
|
||||
import type { IService, PropertyOptions, PropertyAction, PropertyControl, PropertyAssetType, EnumOption, PropertyType } from '@esengine/ecs-framework';
|
||||
import { Injectable, Component, getPropertyMetadata, getComponentInstanceTypeName, isComponentInstanceHiddenInInspector } from '@esengine/ecs-framework';
|
||||
import { createLogger } from '@esengine/ecs-framework';
|
||||
|
||||
const logger = createLogger('PropertyMetadata');
|
||||
|
||||
/**
|
||||
* 不需要在 Inspector 中显示的内部组件类型
|
||||
* 这些组件不使用 @Property 装饰器,因为它们的属性不应该被手动编辑
|
||||
*/
|
||||
const INTERNAL_COMPONENTS = new Set([
|
||||
'HierarchyComponent'
|
||||
]);
|
||||
|
||||
export type { PropertyOptions, PropertyAction, PropertyControl, AssetType, EnumOption };
|
||||
export type { PropertyOptions, PropertyAction, PropertyControl, PropertyAssetType, EnumOption, PropertyType };
|
||||
export type PropertyMetadata = PropertyOptions;
|
||||
export type PropertyType = 'number' | 'integer' | 'string' | 'boolean' | 'color' | 'vector2' | 'vector3' | 'enum' | 'asset' | 'animationClips';
|
||||
|
||||
export interface ComponentMetadata {
|
||||
properties: Record<string, PropertyMetadata>;
|
||||
@@ -46,6 +37,7 @@ export class PropertyMetadataService implements IService {
|
||||
|
||||
/**
|
||||
* 获取组件的所有可编辑属性
|
||||
* Get all editable properties of a component
|
||||
*/
|
||||
public getEditableProperties(component: Component): Record<string, PropertyMetadata> {
|
||||
// 优先使用手动注册的元数据
|
||||
@@ -61,9 +53,11 @@ export class PropertyMetadataService implements IService {
|
||||
}
|
||||
|
||||
// 没有元数据时返回空对象
|
||||
// 内部组件(如 HierarchyComponent)不需要警告
|
||||
if (!INTERNAL_COMPONENTS.has(component.constructor.name)) {
|
||||
logger.warn(`No property metadata found for component: ${component.constructor.name}`);
|
||||
// 使用 @ECSComponent 装饰器的 editor.hideInInspector 选项判断是否为内部组件
|
||||
// Use @ECSComponent decorator's editor.hideInInspector option to check if internal component
|
||||
if (!isComponentInstanceHiddenInInspector(component)) {
|
||||
const componentTypeName = getComponentInstanceTypeName(component);
|
||||
logger.warn(`No property metadata found for component: ${componentTypeName}`);
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
import type { IService } from '@esengine/ecs-framework';
|
||||
import { Injectable, Core, createLogger, SceneSerializer, Scene } from '@esengine/ecs-framework';
|
||||
import type { IService, Entity, PrefabData } from '@esengine/ecs-framework';
|
||||
import {
|
||||
Injectable,
|
||||
Core,
|
||||
createLogger,
|
||||
SceneSerializer,
|
||||
Scene,
|
||||
PrefabSerializer,
|
||||
HierarchySystem,
|
||||
ComponentRegistry
|
||||
} from '@esengine/ecs-framework';
|
||||
import type { ComponentType } from '@esengine/ecs-framework';
|
||||
import type { SceneResourceManager } from '@esengine/asset-system';
|
||||
import type { MessageHub } from './MessageHub';
|
||||
import type { IFileAPI } from '../Types/IFileAPI';
|
||||
@@ -16,6 +26,29 @@ export interface SceneState {
|
||||
isSaved: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 预制体编辑模式状态
|
||||
* Prefab edit mode state
|
||||
*/
|
||||
export interface PrefabEditModeState {
|
||||
/** 是否处于预制体编辑模式 | Whether in prefab edit mode */
|
||||
isActive: boolean;
|
||||
/** 预制体文件路径 | Prefab file path */
|
||||
prefabPath: string;
|
||||
/** 预制体名称 | Prefab name */
|
||||
prefabName: string;
|
||||
/** 预制体 GUID | Prefab GUID */
|
||||
prefabGuid?: string;
|
||||
/** 原始预制体数据(用于比较修改) | Original prefab data (for modification comparison) */
|
||||
originalPrefabData: PrefabData;
|
||||
/** 原场景路径 | Original scene path */
|
||||
originalScenePath: string | null;
|
||||
/** 原场景名称 | Original scene name */
|
||||
originalSceneName: string;
|
||||
/** 原场景是否已修改 | Whether original scene was modified */
|
||||
originalSceneModified: boolean;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class SceneManagerService implements IService {
|
||||
private sceneState: SceneState = {
|
||||
@@ -25,6 +58,12 @@ export class SceneManagerService implements IService {
|
||||
isSaved: false
|
||||
};
|
||||
|
||||
/** 预制体编辑模式状态 | Prefab edit mode state */
|
||||
private prefabEditModeState: PrefabEditModeState | null = null;
|
||||
|
||||
/** 预制体编辑时场景中的根实体 | Root entity in scene during prefab editing */
|
||||
private prefabRootEntity: Entity | null = null;
|
||||
|
||||
private unsubscribeHandlers: Array<() => void> = [];
|
||||
private sceneResourceManager: SceneResourceManager | null = null;
|
||||
|
||||
@@ -196,17 +235,15 @@ export class SceneManagerService implements IService {
|
||||
public async saveSceneAs(filePath?: string): Promise<void> {
|
||||
let path: string | null | undefined = filePath;
|
||||
if (!path) {
|
||||
let defaultName = this.sceneState.sceneName || 'Untitled';
|
||||
const defaultName = this.sceneState.sceneName || 'Untitled';
|
||||
let scenesDir: string | undefined;
|
||||
|
||||
// 获取场景目录,限制保存位置 | Get scenes directory to restrict save location
|
||||
if (this.projectService?.isProjectOpen()) {
|
||||
const scenesPath = this.projectService.getScenesPath();
|
||||
if (scenesPath) {
|
||||
const sep = scenesPath.includes('\\') ? '\\' : '/';
|
||||
defaultName = `${scenesPath}${sep}${defaultName}`;
|
||||
}
|
||||
scenesDir = this.projectService.getScenesPath() ?? undefined;
|
||||
}
|
||||
|
||||
path = await this.fileAPI.saveSceneDialog(defaultName);
|
||||
path = await this.fileAPI.saveSceneDialog(defaultName, scenesDir);
|
||||
if (!path) {
|
||||
return;
|
||||
}
|
||||
@@ -250,17 +287,15 @@ export class SceneManagerService implements IService {
|
||||
public async exportScene(filePath?: string): Promise<void> {
|
||||
let path: string | null | undefined = filePath;
|
||||
if (!path) {
|
||||
let defaultName = (this.sceneState.sceneName || 'Untitled') + '.ecs.bin';
|
||||
const defaultName = (this.sceneState.sceneName || 'Untitled') + '.ecs.bin';
|
||||
let scenesDir: string | undefined;
|
||||
|
||||
// 获取场景目录,限制保存位置 | Get scenes directory to restrict save location
|
||||
if (this.projectService?.isProjectOpen()) {
|
||||
const scenesPath = this.projectService.getScenesPath();
|
||||
if (scenesPath) {
|
||||
const sep = scenesPath.includes('\\') ? '\\' : '/';
|
||||
defaultName = `${scenesPath}${sep}${defaultName}`;
|
||||
}
|
||||
scenesDir = this.projectService.getScenesPath() ?? undefined;
|
||||
}
|
||||
|
||||
path = await this.fileAPI.saveSceneDialog(defaultName);
|
||||
path = await this.fileAPI.saveSceneDialog(defaultName, scenesDir);
|
||||
if (!path) {
|
||||
return;
|
||||
}
|
||||
@@ -309,20 +344,352 @@ export class SceneManagerService implements IService {
|
||||
return true;
|
||||
}
|
||||
|
||||
// ===== 预制体编辑模式 API | Prefab Edit Mode API =====
|
||||
|
||||
/**
|
||||
* 进入预制体编辑模式
|
||||
* Enter prefab edit mode
|
||||
*
|
||||
* @param prefabPath - 预制体文件路径 | Prefab file path
|
||||
*/
|
||||
public async enterPrefabEditMode(prefabPath: string): Promise<void> {
|
||||
// 如果已在预制体编辑模式,先退出
|
||||
// If already in prefab edit mode, exit first
|
||||
if (this.prefabEditModeState?.isActive) {
|
||||
await this.exitPrefabEditMode(false);
|
||||
}
|
||||
|
||||
const scene = Core.scene as Scene | null;
|
||||
if (!scene) {
|
||||
throw new Error('No active scene');
|
||||
}
|
||||
|
||||
try {
|
||||
// 1. 读取预制体文件 | Read prefab file
|
||||
const prefabJson = await this.fileAPI.readFileContent(prefabPath);
|
||||
const prefabData = PrefabSerializer.deserialize(prefabJson);
|
||||
|
||||
// 2. 验证预制体数据 | Validate prefab data
|
||||
const validation = PrefabSerializer.validate(prefabData);
|
||||
if (!validation.valid) {
|
||||
throw new Error(`Invalid prefab: ${validation.errors?.join(', ')}`);
|
||||
}
|
||||
|
||||
// 3. 保存当前场景状态 | Save current scene state
|
||||
const savedScenePath = this.sceneState.currentScenePath;
|
||||
const savedSceneName = this.sceneState.sceneName;
|
||||
const savedSceneModified = this.sceneState.isModified;
|
||||
|
||||
// 4. 请求保存场景快照(通过 MessageHub,由 EngineService 处理)
|
||||
// Request to save scene snapshot (via MessageHub, handled by EngineService)
|
||||
const snapshotSaved = await this.messageHub.request<void, boolean>(
|
||||
'engine:saveSceneSnapshot',
|
||||
undefined,
|
||||
5000
|
||||
).catch(() => false);
|
||||
|
||||
if (!snapshotSaved) {
|
||||
logger.warn('Failed to save scene snapshot, proceeding without snapshot');
|
||||
}
|
||||
|
||||
// 5. 清空场景 | Clear scene
|
||||
scene.entities.removeAllEntities();
|
||||
|
||||
// 5.1 清理查询系统和系统缓存 | Clear query system and system caches
|
||||
scene.querySystem.setEntities([]);
|
||||
scene.clearSystemEntityCaches();
|
||||
|
||||
// 5.2 重置所有系统的实体跟踪状态 | Reset entity tracking for all systems
|
||||
for (const system of scene.systems) {
|
||||
system.resetEntityTracking();
|
||||
}
|
||||
|
||||
// 6. 获取组件注册表 | Get component registry
|
||||
// ComponentRegistry.getAllComponentNames() 返回 Map<string, Function>
|
||||
// 需要转换为 Map<string, ComponentType>
|
||||
const nameToType = ComponentRegistry.getAllComponentNames();
|
||||
const componentRegistry = new Map<string, ComponentType>();
|
||||
nameToType.forEach((type, name) => {
|
||||
componentRegistry.set(name, type as ComponentType);
|
||||
});
|
||||
|
||||
// 7. 实例化预制体到场景 | Instantiate prefab to scene
|
||||
logger.info(`Instantiating prefab with ${componentRegistry.size} registered component types`);
|
||||
logger.debug('Available component types:', Array.from(componentRegistry.keys()));
|
||||
|
||||
this.prefabRootEntity = PrefabSerializer.instantiate(
|
||||
prefabData,
|
||||
scene,
|
||||
componentRegistry,
|
||||
{
|
||||
trackInstance: false, // 编辑模式不追踪实例 | Don't track instance in edit mode
|
||||
preserveIds: false
|
||||
}
|
||||
);
|
||||
|
||||
logger.info(`Prefab instantiated, root entity: ${this.prefabRootEntity?.name} (id: ${this.prefabRootEntity?.id})`);
|
||||
logger.info(`Scene entity count: ${scene.entities.count}`);
|
||||
|
||||
// 7.1 强制重建查询系统 | Force rebuild query system
|
||||
// 使用 setEntities 完全重置,确保所有索引正确重建
|
||||
// Using setEntities to fully reset, ensuring all indexes are correctly rebuilt
|
||||
const allEntities = Array.from(scene.entities.buffer);
|
||||
scene.querySystem.setEntities(allEntities);
|
||||
|
||||
// 7.2 重置所有系统的实体跟踪状态,强制它们重新扫描
|
||||
// Reset all system entity tracking, forcing them to rescan
|
||||
for (const system of scene.systems) {
|
||||
system.resetEntityTracking();
|
||||
}
|
||||
|
||||
// 7.3 清理系统缓存 | Clear system caches
|
||||
scene.clearSystemEntityCaches();
|
||||
|
||||
// 8. 加载场景资源(纹理、音频等)| Load scene resources (textures, audio, etc.)
|
||||
if (this.sceneResourceManager) {
|
||||
await this.sceneResourceManager.loadSceneResources(scene);
|
||||
logger.info('Scene resources loaded for prefab');
|
||||
} else {
|
||||
logger.warn('SceneResourceManager not available, skipping resource loading');
|
||||
}
|
||||
|
||||
// 9. 设置预制体编辑模式状态 | Set prefab edit mode state
|
||||
const prefabName = prefabData.metadata.name || prefabPath.split(/[/\\]/).pop()?.replace('.prefab', '') || 'Prefab';
|
||||
this.prefabEditModeState = {
|
||||
isActive: true,
|
||||
prefabPath,
|
||||
prefabName,
|
||||
prefabGuid: prefabData.metadata.guid,
|
||||
originalPrefabData: prefabData,
|
||||
originalScenePath: savedScenePath,
|
||||
originalSceneName: savedSceneName,
|
||||
originalSceneModified: savedSceneModified
|
||||
};
|
||||
|
||||
// 10. 更新场景状态 | Update scene state
|
||||
this.sceneState = {
|
||||
currentScenePath: null,
|
||||
sceneName: `Prefab: ${prefabName}`,
|
||||
isModified: false,
|
||||
isSaved: true
|
||||
};
|
||||
|
||||
// 11. 同步到 EntityStore | Sync to EntityStore
|
||||
this.entityStore?.syncFromScene();
|
||||
|
||||
// 12. 发布事件 | Publish events
|
||||
await this.messageHub.publish('prefab:editMode:enter', {
|
||||
prefabPath,
|
||||
prefabName,
|
||||
prefabGuid: prefabData.metadata.guid
|
||||
});
|
||||
await this.messageHub.publish('prefab:editMode:changed', {
|
||||
isActive: true,
|
||||
prefabPath,
|
||||
prefabName
|
||||
});
|
||||
|
||||
logger.info(`Entered prefab edit mode: ${prefabPath}`);
|
||||
} catch (error) {
|
||||
logger.error('Failed to enter prefab edit mode:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 退出预制体编辑模式
|
||||
* Exit prefab edit mode
|
||||
*
|
||||
* @param save - 是否保存修改 | Whether to save changes
|
||||
*/
|
||||
public async exitPrefabEditMode(save: boolean = false): Promise<void> {
|
||||
if (!this.prefabEditModeState?.isActive) {
|
||||
logger.warn('Not in prefab edit mode');
|
||||
return;
|
||||
}
|
||||
|
||||
const scene = Core.scene as Scene | null;
|
||||
if (!scene) {
|
||||
throw new Error('No active scene');
|
||||
}
|
||||
|
||||
try {
|
||||
// 1. 如果需要保存,先保存预制体 | If save requested, save prefab first
|
||||
if (save && this.sceneState.isModified) {
|
||||
await this.savePrefab();
|
||||
}
|
||||
|
||||
// 2. 清空当前场景 | Clear current scene
|
||||
scene.entities.removeAllEntities();
|
||||
this.prefabRootEntity = null;
|
||||
|
||||
// 3. 请求恢复场景快照(通过 MessageHub,由 EngineService 处理)
|
||||
// Request to restore scene snapshot (via MessageHub, handled by EngineService)
|
||||
const snapshotRestored = await this.messageHub.request<void, boolean>(
|
||||
'engine:restoreSceneSnapshot',
|
||||
undefined,
|
||||
5000
|
||||
).catch(() => false);
|
||||
|
||||
// 4. 恢复场景状态 | Restore scene state
|
||||
const originalState = this.prefabEditModeState;
|
||||
this.sceneState = {
|
||||
currentScenePath: originalState.originalScenePath,
|
||||
sceneName: originalState.originalSceneName,
|
||||
isModified: originalState.originalSceneModified,
|
||||
isSaved: !originalState.originalSceneModified
|
||||
};
|
||||
|
||||
// 5. 清除预制体编辑模式状态 | Clear prefab edit mode state
|
||||
this.prefabEditModeState = null;
|
||||
|
||||
// 6. 同步到 EntityStore | Sync to EntityStore
|
||||
this.entityStore?.syncFromScene();
|
||||
|
||||
// 7. 发布事件 | Publish events
|
||||
await this.messageHub.publish('prefab:editMode:exit', { saved: save });
|
||||
await this.messageHub.publish('prefab:editMode:changed', {
|
||||
isActive: false
|
||||
});
|
||||
|
||||
if (snapshotRestored) {
|
||||
await this.messageHub.publish('scene:restored', {});
|
||||
}
|
||||
|
||||
logger.info(`Exited prefab edit mode, saved: ${save}`);
|
||||
} catch (error) {
|
||||
logger.error('Failed to exit prefab edit mode:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存预制体
|
||||
* Save prefab
|
||||
*/
|
||||
public async savePrefab(): Promise<void> {
|
||||
if (!this.prefabEditModeState?.isActive) {
|
||||
throw new Error('Not in prefab edit mode');
|
||||
}
|
||||
|
||||
const scene = Core.scene as Scene | null;
|
||||
if (!scene) {
|
||||
throw new Error('No active scene');
|
||||
}
|
||||
|
||||
if (!this.prefabRootEntity) {
|
||||
throw new Error('No prefab root entity');
|
||||
}
|
||||
|
||||
try {
|
||||
const hierarchySystem = scene.getSystem(HierarchySystem) ?? undefined;
|
||||
|
||||
// 1. 从根实体创建预制体数据 | Create prefab data from root entity
|
||||
const newPrefabData = PrefabSerializer.createPrefab(
|
||||
this.prefabRootEntity,
|
||||
{
|
||||
name: this.prefabEditModeState.prefabName,
|
||||
description: this.prefabEditModeState.originalPrefabData.metadata.description,
|
||||
tags: this.prefabEditModeState.originalPrefabData.metadata.tags,
|
||||
includeChildren: true
|
||||
},
|
||||
hierarchySystem
|
||||
);
|
||||
|
||||
// 2. 保持原有 GUID | Preserve original GUID
|
||||
if (this.prefabEditModeState.prefabGuid) {
|
||||
newPrefabData.metadata.guid = this.prefabEditModeState.prefabGuid;
|
||||
}
|
||||
|
||||
// 3. 保持原有创建时间,更新修改时间 | Preserve creation time, update modification time
|
||||
newPrefabData.metadata.createdAt = this.prefabEditModeState.originalPrefabData.metadata.createdAt;
|
||||
newPrefabData.metadata.modifiedAt = Date.now();
|
||||
|
||||
// 4. 序列化并保存 | Serialize and save
|
||||
const prefabJson = PrefabSerializer.serialize(newPrefabData, true);
|
||||
await this.fileAPI.saveProject(this.prefabEditModeState.prefabPath, prefabJson);
|
||||
|
||||
// 5. 更新原始数据(用于后续修改检测)| Update original data (for subsequent modification detection)
|
||||
this.prefabEditModeState.originalPrefabData = newPrefabData;
|
||||
|
||||
// 6. 标记为已保存 | Mark as saved
|
||||
this.sceneState.isModified = false;
|
||||
this.sceneState.isSaved = true;
|
||||
|
||||
// 7. 发布事件 | Publish event
|
||||
await this.messageHub.publish('prefab:saved', {
|
||||
prefabPath: this.prefabEditModeState.prefabPath,
|
||||
prefabName: this.prefabEditModeState.prefabName
|
||||
});
|
||||
|
||||
logger.info(`Prefab saved: ${this.prefabEditModeState.prefabPath}`);
|
||||
} catch (error) {
|
||||
logger.error('Failed to save prefab:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否处于预制体编辑模式
|
||||
* Check if in prefab edit mode
|
||||
*/
|
||||
public isPrefabEditMode(): boolean {
|
||||
return this.prefabEditModeState?.isActive ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取预制体编辑模式状态
|
||||
* Get prefab edit mode state
|
||||
*/
|
||||
public getPrefabEditModeState(): PrefabEditModeState | null {
|
||||
return this.prefabEditModeState ? { ...this.prefabEditModeState } : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查预制体是否已修改
|
||||
* Check if prefab has been modified
|
||||
*/
|
||||
public isPrefabModified(): boolean {
|
||||
return (this.prefabEditModeState?.isActive ?? false) && this.sceneState.isModified;
|
||||
}
|
||||
|
||||
private setupAutoModificationTracking(): void {
|
||||
// 实体级别事件 | Entity-level events
|
||||
const unsubscribeEntityAdded = this.messageHub.subscribe('entity:added', () => {
|
||||
this.markAsModified();
|
||||
});
|
||||
|
||||
const unsubscribeEntityRemoved = this.messageHub.subscribe('entity:removed', () => {
|
||||
this.markAsModified();
|
||||
});
|
||||
|
||||
const unsubscribeEntityReordered = this.messageHub.subscribe('entity:reordered', () => {
|
||||
this.markAsModified();
|
||||
});
|
||||
|
||||
this.unsubscribeHandlers.push(unsubscribeEntityAdded, unsubscribeEntityRemoved, unsubscribeEntityReordered);
|
||||
// 组件级别事件 | Component-level events
|
||||
const unsubscribeComponentAdded = this.messageHub.subscribe('component:added', () => {
|
||||
this.markAsModified();
|
||||
});
|
||||
const unsubscribeComponentRemoved = this.messageHub.subscribe('component:removed', () => {
|
||||
this.markAsModified();
|
||||
});
|
||||
const unsubscribeComponentPropertyChanged = this.messageHub.subscribe('component:property:changed', () => {
|
||||
this.markAsModified();
|
||||
});
|
||||
|
||||
// 通用场景修改事件 | Generic scene modification event
|
||||
const unsubscribeSceneModified = this.messageHub.subscribe('scene:modified', () => {
|
||||
this.markAsModified();
|
||||
});
|
||||
|
||||
this.unsubscribeHandlers.push(
|
||||
unsubscribeEntityAdded,
|
||||
unsubscribeEntityRemoved,
|
||||
unsubscribeEntityReordered,
|
||||
unsubscribeComponentAdded,
|
||||
unsubscribeComponentRemoved,
|
||||
unsubscribeComponentPropertyChanged,
|
||||
unsubscribeSceneModified
|
||||
);
|
||||
|
||||
logger.debug('Auto modification tracking setup complete');
|
||||
}
|
||||
|
||||
@@ -41,6 +41,21 @@ export interface UserScriptInfo {
|
||||
lastModified: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* SDK module info for shim generation.
|
||||
* 用于生成 shim 的 SDK 模块信息。
|
||||
*/
|
||||
export interface SDKModuleInfo {
|
||||
/** Module ID (e.g., "particle", "engine-core") | 模块 ID */
|
||||
id: string;
|
||||
/** Full package name (e.g., "@esengine/particle") | 完整包名 */
|
||||
name: string;
|
||||
/** Whether module has runtime code | 模块是否有运行时代码 */
|
||||
hasRuntime?: boolean;
|
||||
/** Global key for window.__ESENGINE__ (optional, defaults to camelCase of id) | 全局键名 */
|
||||
globalKey?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* User code compilation options.
|
||||
* 用户代码编译选项。
|
||||
@@ -58,6 +73,16 @@ export interface UserCodeCompileOptions {
|
||||
minify?: boolean;
|
||||
/** Output format | 输出格式 */
|
||||
format?: 'esm' | 'iife';
|
||||
/**
|
||||
* SDK modules for shim generation.
|
||||
* 用于生成 shim 的 SDK 模块列表。
|
||||
*
|
||||
* If provided, shims will be created for these modules.
|
||||
* Typically obtained from RuntimeResolver.getAvailableModules().
|
||||
* 如果提供,将为这些模块创建 shim。
|
||||
* 通常从 RuntimeResolver.getAvailableModules() 获取。
|
||||
*/
|
||||
sdkModules?: SDKModuleInfo[];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -124,6 +149,62 @@ export interface HotReloadEvent {
|
||||
newModule: UserCodeModule;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hot reloadable component/system interface.
|
||||
* 可热更新的组件/系统接口。
|
||||
*
|
||||
* Implement this interface in user components or systems to preserve state
|
||||
* during hot reload. Without this interface, hot reload only updates the
|
||||
* prototype chain; with it, you can save and restore custom state.
|
||||
*
|
||||
* 在用户组件或系统中实现此接口以在热更新时保留状态。
|
||||
* 如果不实现此接口,热更新只会更新原型链;实现后,可以保存和恢复自定义状态。
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* @ECSComponent('MyComponent')
|
||||
* class MyComponent extends Component implements IHotReloadable {
|
||||
* private _cachedData: Map<string, any> = new Map();
|
||||
*
|
||||
* onBeforeHotReload(): Record<string, unknown> {
|
||||
* // Save state that needs to survive hot reload
|
||||
* return {
|
||||
* cachedData: Array.from(this._cachedData.entries())
|
||||
* };
|
||||
* }
|
||||
*
|
||||
* onAfterHotReload(state: Record<string, unknown>): void {
|
||||
* // Restore state after hot reload
|
||||
* const entries = state.cachedData as [string, any][];
|
||||
* this._cachedData = new Map(entries);
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export interface IHotReloadable {
|
||||
/**
|
||||
* Called before hot reload to save state.
|
||||
* 在热更新前调用以保存状态。
|
||||
*
|
||||
* Return an object containing any state that needs to survive the hot reload.
|
||||
* The returned object will be passed to onAfterHotReload after the prototype is updated.
|
||||
*
|
||||
* 返回包含需要保留的状态的对象。
|
||||
* 返回的对象将在原型更新后传递给 onAfterHotReload。
|
||||
*
|
||||
* @returns State object to preserve | 需要保留的状态对象
|
||||
*/
|
||||
onBeforeHotReload?(): Record<string, unknown>;
|
||||
|
||||
/**
|
||||
* Called after hot reload to restore state.
|
||||
* 在热更新后调用以恢复状态。
|
||||
*
|
||||
* @param state - State saved by onBeforeHotReload | onBeforeHotReload 保存的状态
|
||||
*/
|
||||
onAfterHotReload?(state: Record<string, unknown>): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* User Code Service interface.
|
||||
* 用户代码服务接口。
|
||||
|
||||
@@ -23,7 +23,9 @@ import type {
|
||||
CompileError,
|
||||
UserCodeModule,
|
||||
HotReloadEvent,
|
||||
IHotReloadOptions
|
||||
IHotReloadOptions,
|
||||
SDKModuleInfo,
|
||||
IHotReloadable
|
||||
} from './IUserCodeService';
|
||||
import {
|
||||
UserCodeTarget,
|
||||
@@ -189,22 +191,15 @@ export class UserCodeService implements IService, IUserCodeService {
|
||||
await this._fileSystem.writeFile(entryPath, entryContent);
|
||||
|
||||
// Create shim files for framework dependencies | 创建框架依赖的 shim 文件
|
||||
await this._createDependencyShims(outputDir, options.target);
|
||||
// Returns mapping from package name to shim path
|
||||
// 返回包名到 shim 路径的映射
|
||||
const alias = await this._createDependencyShims(outputDir, options.sdkModules);
|
||||
|
||||
// Determine global name for IIFE output | 确定 IIFE 输出的全局名称
|
||||
const globalName = options.target === UserCodeTarget.Runtime
|
||||
? EditorConfig.globals.userRuntimeExports
|
||||
: EditorConfig.globals.userEditorExports;
|
||||
|
||||
// Build alias map for framework dependencies | 构建框架依赖的别名映射
|
||||
const shimPath = `${outputDir}${sep}_shim_ecs_framework.js`.replace(/\\/g, '/');
|
||||
const alias: Record<string, string> = {
|
||||
'@esengine/ecs-framework': shimPath,
|
||||
'@esengine/core': shimPath,
|
||||
'@esengine/engine-core': shimPath,
|
||||
'@esengine/math': shimPath
|
||||
};
|
||||
|
||||
// Compile using esbuild (via Tauri command or direct) | 使用 esbuild 编译
|
||||
// Use IIFE format to avoid ES module import issues in Tauri
|
||||
// 使用 IIFE 格式以避免 Tauri 中的 ES 模块导入问题
|
||||
@@ -428,6 +423,11 @@ export class UserCodeService implements IService, IUserCodeService {
|
||||
* 这是热更新的核心 - 它更新现有实例的原型链,使它们使用更新后类的新方法,
|
||||
* 同时保留它们的数据(属性)。
|
||||
*
|
||||
* If a component implements IHotReloadable, its onBeforeHotReload/onAfterHotReload
|
||||
* methods will be called to preserve and restore custom state.
|
||||
* 如果组件实现了 IHotReloadable,将调用其 onBeforeHotReload/onAfterHotReload
|
||||
* 方法来保存和恢复自定义状态。
|
||||
*
|
||||
* @param module - New user code module | 新的用户代码模块
|
||||
* @returns Number of instances updated | 更新的实例数量
|
||||
*/
|
||||
@@ -439,7 +439,7 @@ export class UserCodeService implements IService, IUserCodeService {
|
||||
// Access scene through Core.scene
|
||||
// 通过 Core.scene 访问场景
|
||||
const sdkGlobal = (window as any)[EditorConfig.globals.sdk];
|
||||
const Core = sdkGlobal?.ecsFramework?.Core;
|
||||
const Core = sdkGlobal?.Core;
|
||||
const scene = Core?.scene;
|
||||
if (!scene || !scene.entities) {
|
||||
logger.warn('No active scene for hot reload | 没有活动场景用于热更新');
|
||||
@@ -480,7 +480,39 @@ export class UserCodeService implements IService, IUserCodeService {
|
||||
// Update the prototype chain to use the new class
|
||||
// 更新原型链以使用新类
|
||||
try {
|
||||
// Check if component implements IHotReloadable
|
||||
// 检查组件是否实现了 IHotReloadable
|
||||
const hotReloadable = component as IHotReloadable;
|
||||
let savedState: Record<string, unknown> | undefined;
|
||||
|
||||
// Save state before hot reload (if implemented)
|
||||
// 在热更新前保存状态(如果实现了)
|
||||
if (typeof hotReloadable.onBeforeHotReload === 'function') {
|
||||
try {
|
||||
savedState = hotReloadable.onBeforeHotReload();
|
||||
logger.debug(`Saved hot reload state for ${typeName}`, {
|
||||
stateKeys: savedState ? Object.keys(savedState) : []
|
||||
});
|
||||
} catch (err) {
|
||||
logger.warn(`onBeforeHotReload failed for ${typeName}:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
// Update prototype chain
|
||||
// 更新原型链
|
||||
Object.setPrototypeOf(component, newClass.prototype);
|
||||
|
||||
// Restore state after hot reload (if implemented and state was saved)
|
||||
// 在热更新后恢复状态(如果实现了且有保存的状态)
|
||||
if (savedState && typeof hotReloadable.onAfterHotReload === 'function') {
|
||||
try {
|
||||
hotReloadable.onAfterHotReload(savedState);
|
||||
logger.debug(`Restored hot reload state for ${typeName}`);
|
||||
} catch (err) {
|
||||
logger.warn(`onAfterHotReload failed for ${typeName}:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
updatedCount++;
|
||||
logger.debug(`Hot reloaded component instance: ${typeName} on entity ${entity.name || entity.id}`);
|
||||
} catch (err) {
|
||||
@@ -730,7 +762,7 @@ export class UserCodeService implements IService, IUserCodeService {
|
||||
// Initialize hot reload coordinator with Core reference
|
||||
// 使用 Core 引用初始化热更新协调器
|
||||
const sdkGlobal = (window as any)[EditorConfig.globals.sdk];
|
||||
const Core = sdkGlobal?.ecsFramework?.Core;
|
||||
const Core = sdkGlobal?.Core;
|
||||
if (Core) {
|
||||
this._hotReloadCoordinator.initialize(Core);
|
||||
} else {
|
||||
@@ -756,10 +788,23 @@ export class UserCodeService implements IService, IUserCodeService {
|
||||
}>('user-code:file-changed', async (event) => {
|
||||
const { changeType, paths } = event.payload;
|
||||
|
||||
logger.info('File change detected | 检测到文件变更', { changeType, paths });
|
||||
// 只处理脚本文件 | Only process script files
|
||||
const scriptExtensions = ['.ts', '.tsx', '.js', '.jsx'];
|
||||
const scriptPaths = paths.filter(p => {
|
||||
const ext = p.substring(p.lastIndexOf('.')).toLowerCase();
|
||||
return scriptExtensions.includes(ext);
|
||||
});
|
||||
|
||||
// 如果没有脚本文件变更,跳过热更新 | Skip hot reload if no script files changed
|
||||
if (scriptPaths.length === 0) {
|
||||
logger.debug('No script files in change event, skipping hot reload | 变更事件中没有脚本文件,跳过热更新');
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info('Script file change detected | 检测到脚本文件变更', { changeType, paths: scriptPaths });
|
||||
|
||||
// Determine which targets are affected | 确定受影响的目标
|
||||
const isEditorChange = paths.some(p =>
|
||||
const isEditorChange = scriptPaths.some(p =>
|
||||
p.includes(`${EDITOR_SCRIPTS_DIR}/`) || p.includes(`${EDITOR_SCRIPTS_DIR}\\`)
|
||||
);
|
||||
const target = isEditorChange ? UserCodeTarget.Editor : UserCodeTarget.Runtime;
|
||||
@@ -790,7 +835,7 @@ export class UserCodeService implements IService, IUserCodeService {
|
||||
// Create hot reload event | 创建热更新事件
|
||||
const reloadEvent: HotReloadEvent = {
|
||||
target,
|
||||
changedFiles: paths,
|
||||
changedFiles: scriptPaths,
|
||||
previousModule,
|
||||
newModule
|
||||
};
|
||||
@@ -1014,62 +1059,61 @@ export class UserCodeService implements IService, IUserCodeService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Create shim files that map global variables to module imports.
|
||||
* 创建将全局变量映射到模块导入的 shim 文件。
|
||||
* Create shim file that maps SDK global variable to module import.
|
||||
* 创建将 SDK 全局变量映射到模块导入的 shim 文件。
|
||||
*
|
||||
* This is used for IIFE format to resolve external dependencies.
|
||||
* The shim exports the global __ESENGINE__.ecsFramework which is set by PluginSDKRegistry.
|
||||
* Creates a single shim for @esengine/sdk.
|
||||
* 这用于 IIFE 格式解析外部依赖。
|
||||
* shim 导出全局的 __ESENGINE__.ecsFramework,由 PluginSDKRegistry 设置。
|
||||
* 只创建一个 @esengine/sdk 的 shim。
|
||||
*
|
||||
* @param outputDir - Output directory | 输出目录
|
||||
* @param target - Target environment | 目标环境
|
||||
* @returns Array of shim file paths | shim 文件路径数组
|
||||
* @param _sdkModules - Deprecated, not used | 已废弃,不再使用
|
||||
* @returns Mapping from package name to shim path | 包名到 shim 路径的映射
|
||||
*/
|
||||
private async _createDependencyShims(
|
||||
outputDir: string,
|
||||
target: UserCodeTarget
|
||||
): Promise<string[]> {
|
||||
_sdkModules?: SDKModuleInfo[]
|
||||
): Promise<Record<string, string>> {
|
||||
const sep = outputDir.includes('\\') ? '\\' : '/';
|
||||
const shimPaths: string[] = [];
|
||||
|
||||
// Create shim for @esengine/ecs-framework | 为 @esengine/ecs-framework 创建 shim
|
||||
// This uses window[EditorConfig.globals.sdk].ecsFramework set by PluginSDKRegistry
|
||||
// 这使用 PluginSDKRegistry 设置的 window[EditorConfig.globals.sdk].ecsFramework
|
||||
const ecsShimPath = `${outputDir}${sep}_shim_ecs_framework.js`;
|
||||
const sdkGlobalName = EditorConfig.globals.sdk;
|
||||
const ecsShimContent = `// Shim for @esengine/ecs-framework
|
||||
// Maps to window.${sdkGlobalName}.ecsFramework set by PluginSDKRegistry
|
||||
module.exports = (typeof window !== 'undefined' && window.${sdkGlobalName} && window.${sdkGlobalName}.ecsFramework) || {};
|
||||
`;
|
||||
await this._fileSystem.writeFile(ecsShimPath, ecsShimContent);
|
||||
shimPaths.push(ecsShimPath);
|
||||
|
||||
return shimPaths;
|
||||
// Create single SDK shim
|
||||
// 创建单一 SDK shim
|
||||
const shimPath = `${outputDir}${sep}_shim_sdk.js`;
|
||||
const shimContent = `// Shim for @esengine/sdk
|
||||
// Maps to window.${sdkGlobalName}
|
||||
// User code imports from '@esengine/sdk' will use this shim
|
||||
module.exports = (typeof window !== 'undefined' && window.${sdkGlobalName}) || {};
|
||||
`;
|
||||
await this._fileSystem.writeFile(shimPath, shimContent);
|
||||
const normalizedPath = shimPath.replace(/\\/g, '/');
|
||||
|
||||
logger.info('Created SDK shim', { path: normalizedPath });
|
||||
|
||||
return {
|
||||
'@esengine/sdk': normalizedPath
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get external dependencies that should not be bundled.
|
||||
* 获取不应打包的外部依赖。
|
||||
*
|
||||
* Only @esengine/sdk is external since user code should import from SDK.
|
||||
* 只有 @esengine/sdk 是外部依赖,因为用户代码应该从 SDK 导入。
|
||||
*/
|
||||
private _getExternalDependencies(target: UserCodeTarget): string[] {
|
||||
const common = [
|
||||
'@esengine/ecs-framework',
|
||||
'@esengine/engine-core',
|
||||
'@esengine/core',
|
||||
'@esengine/math'
|
||||
];
|
||||
|
||||
private _getExternalDependencies(target: UserCodeTarget, _sdkModules?: SDKModuleInfo[]): string[] {
|
||||
if (target === UserCodeTarget.Editor) {
|
||||
return [
|
||||
...common,
|
||||
'@esengine/sdk',
|
||||
'@esengine/editor-core',
|
||||
'react',
|
||||
'react-dom'
|
||||
];
|
||||
}
|
||||
|
||||
return common;
|
||||
return ['@esengine/sdk'];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1093,6 +1137,19 @@ module.exports = (typeof window !== 'undefined' && window.${sdkGlobalName} && wi
|
||||
try {
|
||||
// Check if we're in Tauri environment | 检查是否在 Tauri 环境
|
||||
if (PlatformDetector.isTauriEnvironment()) {
|
||||
// Log compilation options for debugging
|
||||
// 记录编译选项用于调试
|
||||
logger.info('Running esbuild compilation', {
|
||||
entry: options.entryPath,
|
||||
output: options.outputPath,
|
||||
format: options.format,
|
||||
aliasCount: options.alias ? Object.keys(options.alias).length : 0
|
||||
});
|
||||
|
||||
if (options.alias) {
|
||||
logger.debug('esbuild alias mappings:', options.alias);
|
||||
}
|
||||
|
||||
// Use Tauri command | 使用 Tauri 命令
|
||||
const { invoke } = await import('@tauri-apps/api/core');
|
||||
|
||||
|
||||
@@ -13,10 +13,13 @@ export interface IFileAPI {
|
||||
|
||||
/**
|
||||
* 打开保存场景对话框
|
||||
* @param defaultName 默认文件名
|
||||
* @returns 用户选择的文件路径,取消则返回 null
|
||||
* Open save scene dialog
|
||||
*
|
||||
* @param defaultName 默认文件名 | Default file name
|
||||
* @param scenesDir 场景目录(限制保存位置)| Scenes directory (restrict save location)
|
||||
* @returns 用户选择的文件路径,取消则返回 null | Selected path or null
|
||||
*/
|
||||
saveSceneDialog(defaultName?: string): Promise<string | null>;
|
||||
saveSceneDialog(defaultName?: string, scenesDir?: string): Promise<string | null>;
|
||||
|
||||
/**
|
||||
* 读取文件内容
|
||||
|
||||
@@ -49,6 +49,7 @@ export * from './Services/AssetRegistryService';
|
||||
export * from './Services/IViewportService';
|
||||
export * from './Services/PreviewSceneService';
|
||||
export * from './Services/EditorViewportService';
|
||||
export * from './Services/PrefabService';
|
||||
|
||||
// Build System | 构建系统
|
||||
export * from './Services/Build';
|
||||
|
||||
@@ -19,10 +19,11 @@
|
||||
* ```
|
||||
*/
|
||||
|
||||
import { createServiceToken } from '@esengine/engine-core';
|
||||
import { createServiceToken } from '@esengine/ecs-framework';
|
||||
import type { LocaleService, Locale, TranslationParams, PluginTranslations } from './Services/LocaleService';
|
||||
import type { MessageHub, MessageHandler, RequestHandler } from './Services/MessageHub';
|
||||
import type { EntityStoreService, EntityTreeNode } from './Services/EntityStoreService';
|
||||
import type { PrefabService, PrefabPropertyOverride, IPrefabFileAPI } from './Services/PrefabService';
|
||||
|
||||
// ============================================================================
|
||||
// LocaleService Token
|
||||
@@ -150,8 +151,61 @@ export type { Locale, TranslationParams, PluginTranslations } from './Services/L
|
||||
export type { MessageHandler, RequestHandler } from './Services/MessageHub';
|
||||
export type { EntityTreeNode } from './Services/EntityStoreService';
|
||||
|
||||
// ============================================================================
|
||||
// PrefabService Token
|
||||
// 预制体服务令牌
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* PrefabService 接口
|
||||
* PrefabService interface
|
||||
*
|
||||
* 提供类型安全的预制体服务访问接口。
|
||||
* Provides type-safe prefab service access interface.
|
||||
*/
|
||||
export interface IPrefabService {
|
||||
/** 设置文件 API | Set file API */
|
||||
setFileAPI(fileAPI: IPrefabFileAPI): void;
|
||||
/** 检查是否为预制体实例 | Check if prefab instance */
|
||||
isPrefabInstance(entity: unknown): boolean;
|
||||
/** 检查是否为预制体实例根节点 | Check if prefab instance root */
|
||||
isPrefabInstanceRoot(entity: unknown): boolean;
|
||||
/** 获取预制体实例组件 | Get prefab instance component */
|
||||
getPrefabInstanceComponent(entity: unknown): unknown | null;
|
||||
/** 获取预制体实例根实体 | Get prefab instance root entity */
|
||||
getPrefabInstanceRoot(entity: unknown): unknown | null;
|
||||
/** 获取属性覆盖列表 | Get property overrides */
|
||||
getOverrides(entity: unknown): PrefabPropertyOverride[];
|
||||
/** 检查是否有修改 | Check if has modifications */
|
||||
hasModifications(entity: unknown): boolean;
|
||||
/** 获取修改数量 | Get modification count */
|
||||
getModificationCount(entity: unknown): number;
|
||||
/** 应用修改到源预制体 | Apply to source prefab */
|
||||
applyToPrefab(entity: unknown): Promise<boolean>;
|
||||
/** 还原实例到源预制体状态 | Revert instance to source prefab state */
|
||||
revertInstance(entity: unknown): Promise<boolean>;
|
||||
/** 还原单个属性 | Revert single property */
|
||||
revertProperty(entity: unknown, componentType: string, propertyPath: string): Promise<boolean>;
|
||||
/** 断开预制体链接 | Break prefab link */
|
||||
breakPrefabLink(entity: unknown): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 预制体服务令牌
|
||||
* Prefab service token
|
||||
*
|
||||
* 用于注册和获取预制体服务。
|
||||
* For registering and getting prefab service.
|
||||
*/
|
||||
export const PrefabServiceToken = createServiceToken<IPrefabService>('prefabService');
|
||||
|
||||
// Re-export types for convenience
|
||||
// 重新导出类型方便使用
|
||||
export type { PrefabPropertyOverride, IPrefabFileAPI } from './Services/PrefabService';
|
||||
|
||||
// Re-export classes for direct use (backwards compatibility)
|
||||
// 重新导出类以供直接使用(向后兼容)
|
||||
export { LocaleService } from './Services/LocaleService';
|
||||
export { MessageHub } from './Services/MessageHub';
|
||||
export { EntityStoreService } from './Services/EntityStoreService';
|
||||
export { PrefabService } from './Services/PrefabService';
|
||||
|
||||
Reference in New Issue
Block a user