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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

@@ -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.
* 用户代码服务接口。

View File

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

View File

@@ -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>;
/**
* 读取文件内容

View File

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

View File

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