feat: 纹理路径稳定 ID 与架构改进 (#305)

* feat(asset-system): 实现路径稳定 ID 生成器

使用 FNV-1a hash 算法为纹理生成稳定的运行时 ID:
- 新增 _pathIdCache 静态缓存,跨 Play/Stop 循环保持稳定
- 新增 getStableIdForPath() 方法,相同路径永远返回相同 ID
- 修改 loadTextureForComponent/loadTextureByGuid 使用稳定 ID
- clearTextureMappings() 不再清除 _pathIdCache

这解决了 Play/Stop 后纹理 ID 失效的根本问题。

* fix(runtime-core): 移除 Play/Stop 循环中的 clearTextureMappings 调用

使用路径稳定 ID 后,不再需要在快照保存/恢复时清除纹理缓存:
- saveSceneSnapshot() 移除 clearTextureMappings() 调用
- restoreSceneSnapshot() 移除 clearTextureMappings() 调用
- 组件保存的 textureId 在 Play/Stop 后仍然有效

* fix(editor-core): 修复场景切换时的资源泄漏

在 openScene() 加载新场景前先卸载旧场景资源:
- 调用 sceneResourceManager.unloadSceneResources() 释放旧资源
- 使用引用计数机制,仅卸载不再被引用的资源
- 路径稳定 ID 缓存不受影响,保持 ID 稳定性

* fix(runtime-core): 修复 PluginManager 组件注册类型错误

将 ComponentRegistry 类改为 GlobalComponentRegistry 实例:
- registerComponents() 期望 IComponentRegistry 接口实例
- GlobalComponentRegistry 是 ComponentRegistry 的全局实例

* refactor(core): 提取 IComponentRegistry 接口

将组件注册表抽象为接口,支持场景级组件注册:
- 新增 IComponentRegistry 接口定义
- Scene 持有独立的 componentRegistry 实例
- 支持从 GlobalComponentRegistry 克隆
- 各系统支持传入自定义注册表

* refactor(engine-core): 改进插件服务注册机制

- 更新 IComponentRegistry 类型引用
- 优化 PluginServiceRegistry 服务管理

* refactor(modules): 适配新的组件注册接口

更新各模块 RuntimeModule 使用 IComponentRegistry 接口:
- audio, behavior-tree, camera
- sprite, tilemap, world-streaming

* fix(physics-rapier2d): 修复物理插件组件注册

- PhysicsEditorPlugin 添加 runtimeModule 引用
- 适配 IComponentRegistry 接口
- 修复物理组件在场景加载时未注册的问题

* feat(editor-core): 添加 UserCodeService 就绪信号机制

- 新增 waitForReady()/signalReady() API
- 支持等待用户脚本编译完成
- 解决场景加载时组件未注册的时序问题

* fix(editor-app): 在编译完成后调用 signalReady()

确保用户脚本编译完成后发出就绪信号:
- 编译成功后调用 userCodeService.signalReady()
- 编译失败也要发出信号,避免阻塞场景加载

* feat(editor-core): 改进编辑器核心服务

- EntityStoreService 添加调试日志
- AssetRegistryService 优化资产注册
- PluginManager 改进插件管理
- IFileAPI 添加 getFileMtime 接口

* feat(engine): 改进 Rust 纹理管理器

- 支持任意 ID 的纹理加载(非递增)
- 添加纹理状态追踪 API
- 优化纹理缓存清理机制
- 更新 TypeScript 绑定

* feat(ui): 添加场景切换和文本闪烁组件

新增组件:
- SceneLoadTriggerComponent: 场景切换触发器
- TextBlinkComponent: 文本闪烁效果

新增系统:
- SceneLoadTriggerSystem: 处理场景切换逻辑
- TextBlinkSystem: 处理文本闪烁动画

其他改进:
- UIRuntimeModule 适配新组件注册接口
- UI 渲染系统优化

* feat(editor-app): 添加外部文件修改检测

- 新增 ExternalModificationDialog 组件
- TauriFileAPI 支持 getFileMtime
- 场景文件被外部修改时提示用户

* feat(editor-app): 添加渲染调试面板

- 新增 RenderDebugService 和调试面板 UI
- App/ContentBrowser 添加调试日志
- TitleBar/Viewport 优化
- DialogManager 改进

* refactor(editor-app): 编辑器服务和组件优化

- EngineService 改进引擎集成
- EditorEngineSync 同步优化
- AssetFileInspector 改进
- VectorFieldEditors 优化
- InstantiatePrefabCommand 改进

* feat(i18n): 更新国际化翻译

- 添加新功能相关翻译
- 更新中文、英文、西班牙文

* feat(tauri): 添加文件修改时间查询命令

- 新增 get_file_mtime 命令
- 支持检测文件外部修改

* refactor(particle): 粒子系统改进

- 适配新的组件注册接口
- ParticleSystem 优化
- 添加单元测试

* refactor(platform): 平台适配层优化

- BrowserRuntime 改进
- 新增 RuntimeSceneManager 服务
- 导出优化

* refactor(asset-system-editor): 资产元数据改进

- AssetMetaFile 优化
- 导出调整

* fix(asset-system): 移除未使用的 TextureLoader 导入

* fix(tests): 更新测试以使用 GlobalComponentRegistry 实例

修复多个测试文件以适配 ComponentRegistry 从静态类变为实例类的变更:
- ComponentStorage.test.ts: 使用 GlobalComponentRegistry.reset()
- EntitySerializer.test.ts: 使用 GlobalComponentRegistry 实例
- IncrementalSerialization.test.ts: 使用 GlobalComponentRegistry 实例
- SceneSerializer.test.ts: 使用 GlobalComponentRegistry 实例
- ComponentRegistry.extended.test.ts: 使用 GlobalComponentRegistry,同时注册到 scene.componentRegistry
- SystemTypes.test.ts: 在 Scene 创建前注册组件
- QuerySystem.test.ts: mockScene 添加 componentRegistry
This commit is contained in:
YHH
2025-12-16 12:46:14 +08:00
committed by GitHub
parent d834ca5e77
commit ed8f6e283b
107 changed files with 7399 additions and 847 deletions

View File

@@ -3,7 +3,7 @@
* Unified Plugin Manager
*/
import { createLogger, ComponentRegistry } from '@esengine/ecs-framework';
import { createLogger, GlobalComponentRegistry } from '@esengine/ecs-framework';
import type { IScene, ServiceContainer, IService } from '@esengine/ecs-framework';
import type {
ModuleManifest,
@@ -670,9 +670,9 @@ export class PluginManager implements IService {
// 注册组件(使用包装的 Registry 来跟踪)
// Register components (use wrapped registry to track)
if (runtimeModule.registerComponents) {
const componentsBefore = new Set(ComponentRegistry.getRegisteredComponents().map(c => c.name));
runtimeModule.registerComponents(ComponentRegistry);
const componentsAfter = ComponentRegistry.getRegisteredComponents();
const componentsBefore = new Set(GlobalComponentRegistry.getRegisteredComponents().map(c => c.name));
runtimeModule.registerComponents(GlobalComponentRegistry);
const componentsAfter = GlobalComponentRegistry.getRegisteredComponents();
// 跟踪新注册的组件
// Track newly registered components
@@ -779,7 +779,7 @@ export class PluginManager implements IService {
if (resources.componentTypeNames.length > 0) {
for (const componentName of resources.componentTypeNames) {
try {
ComponentRegistry.unregister(componentName);
GlobalComponentRegistry.unregister(componentName);
logger.debug(`Component unregistered: ${componentName}`);
} catch (e) {
logger.error(`Failed to unregister component ${componentName}:`, e);
@@ -900,7 +900,7 @@ export class PluginManager implements IService {
const runtimeModule = plugin.plugin.runtimeModule;
if (runtimeModule?.registerComponents) {
try {
runtimeModule.registerComponents(ComponentRegistry);
runtimeModule.registerComponents(GlobalComponentRegistry);
logger.debug(`Components registered for: ${pluginId}`);
} catch (e) {
logger.error(`Failed to register components for ${pluginId}:`, e);

View File

@@ -394,8 +394,14 @@ export class AssetRegistryService implements IService {
// 处理文件创建 - 注册新资产并生成 .meta
if (changeType === 'create' || changeType === 'modify') {
for (const absolutePath of paths) {
// Skip .meta files
if (absolutePath.endsWith('.meta')) continue;
// Handle .meta file changes - invalidate cache
// 处理 .meta 文件变化 - 使缓存失效
if (absolutePath.endsWith('.meta')) {
const assetPath = absolutePath.slice(0, -5); // Remove '.meta' suffix
this._metaManager.invalidateCache(assetPath);
logger.debug(`Meta file changed, invalidated cache for: ${assetPath}`);
continue;
}
// Only process files in managed directories
// 只处理托管目录中的文件
@@ -406,8 +412,14 @@ export class AssetRegistryService implements IService {
}
} else if (changeType === 'remove') {
for (const absolutePath of paths) {
// Skip .meta files
if (absolutePath.endsWith('.meta')) continue;
// Handle .meta file deletion - invalidate cache
// 处理 .meta 文件删除 - 使缓存失效
if (absolutePath.endsWith('.meta')) {
const assetPath = absolutePath.slice(0, -5);
this._metaManager.invalidateCache(assetPath);
logger.debug(`Meta file removed, invalidated cache for: ${assetPath}`);
continue;
}
// Only process files in managed directories
// 只处理托管目录中的文件

View File

@@ -95,6 +95,9 @@ export class EntityStoreService implements IService {
this.entities.clear();
this.rootEntityIds = [];
// 调试:打印场景实体信息 | Debug: print scene entity info
logger.info(`[syncFromScene] Scene name: ${scene.name}, entities.count: ${scene.entities.count}`);
let entityCount = 0;
scene.entities.forEach((entity) => {
entityCount++;
@@ -106,7 +109,7 @@ export class EntityStoreService implements IService {
}
});
logger.debug(`syncFromScene: synced ${entityCount} entities, ${this.rootEntityIds.length} root entities`);
logger.info(`[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)

View File

@@ -7,7 +7,7 @@ import {
Scene,
PrefabSerializer,
HierarchySystem,
ComponentRegistry
GlobalComponentRegistry
} from '@esengine/ecs-framework';
import type { ComponentType } from '@esengine/ecs-framework';
import type { SceneResourceManager } from '@esengine/asset-system';
@@ -24,6 +24,10 @@ export interface SceneState {
sceneName: string;
isModified: boolean;
isSaved: boolean;
/** 文件最后已知的修改时间(毫秒)| Last known file modification time (ms) */
lastKnownMtime: number | null;
/** 文件是否被外部修改 | Whether file was modified externally */
externallyModified: boolean;
}
/**
@@ -55,7 +59,9 @@ export class SceneManagerService implements IService {
currentScenePath: null,
sceneName: 'Untitled',
isModified: false,
isSaved: false
isSaved: false,
lastKnownMtime: null,
externallyModified: false
};
/** 预制体编辑模式状态 | Prefab edit mode state */
@@ -118,7 +124,9 @@ export class SceneManagerService implements IService {
currentScenePath: null,
sceneName: 'Untitled',
isModified: false,
isSaved: false
isSaved: false,
lastKnownMtime: null,
externallyModified: false
};
// 同步到 EntityStore
@@ -148,6 +156,18 @@ export class SceneManagerService implements IService {
}
}
// 在加载新场景前,清理旧场景的纹理映射(释放 GPU 资源)
// Before loading new scene, clear old scene's texture mappings (release GPU resources)
// 注意:路径稳定 ID 缓存 (_pathIdCache) 不会被清除
// Note: Path-stable ID cache (_pathIdCache) is NOT cleared
if (this.sceneResourceManager) {
const oldScene = Core.scene as Scene | null;
if (oldScene && this.sceneState.currentScenePath) {
logger.info(`[openScene] Unloading old scene resources from: ${this.sceneState.currentScenePath}`);
await this.sceneResourceManager.unloadSceneResources(oldScene);
}
}
try {
const jsonData = await this.fileAPI.readFileContent(path);
@@ -165,10 +185,42 @@ export class SceneManagerService implements IService {
// Ensure isEditorMode is set in editor to defer component lifecycle callbacks
scene.isEditorMode = true;
// 调试:检查缺失的组件类型 | Debug: check missing component types
const registeredComponents = GlobalComponentRegistry.getAllComponentNames();
try {
const sceneData = JSON.parse(jsonData);
const requiredTypes = new Set<string>();
for (const entity of sceneData.entities || []) {
for (const comp of entity.components || []) {
requiredTypes.add(comp.type);
}
}
// 检查缺失的组件类型 | Check missing component types
const missingTypes = Array.from(requiredTypes).filter(t => !registeredComponents.has(t));
if (missingTypes.length > 0) {
logger.warn(`[SceneManagerService.openScene] Missing component types (scene will load without these):`, missingTypes);
logger.debug(`Registered components (${registeredComponents.size}):`, Array.from(registeredComponents.keys()));
}
} catch (e) {
// JSON parsing should not fail at this point since we validated earlier
}
// 调试:反序列化前场景状态 | Debug: scene state before deserialize
logger.info(`[openScene] Before deserialize: entities.count = ${scene.entities.count}`);
scene.deserialize(jsonData, {
strategy: 'replace'
});
// 调试:反序列化后场景状态 | Debug: scene state after deserialize
logger.info(`[openScene] After deserialize: entities.count = ${scene.entities.count}`);
if (scene.entities.count > 0) {
const entityNames: string[] = [];
scene.entities.forEach(e => entityNames.push(e.name));
logger.info(`[openScene] Entity names: ${entityNames.join(', ')}`);
}
// 加载场景资源 / Load scene resources
if (this.sceneResourceManager) {
await this.sceneResourceManager.loadSceneResources(scene);
@@ -179,11 +231,23 @@ export class SceneManagerService implements IService {
const fileName = path.split(/[/\\]/).pop() || 'Untitled';
const sceneName = fileName.replace('.ecs', '');
// 获取文件修改时间 | Get file modification time
let mtime: number | null = null;
if (this.fileAPI.getFileMtime) {
try {
mtime = await this.fileAPI.getFileMtime(path);
} catch (e) {
logger.warn('Failed to get file mtime:', e);
}
}
this.sceneState = {
currentScenePath: path,
sceneName,
isModified: false,
isSaved: true
isSaved: true,
lastKnownMtime: mtime,
externallyModified: false
};
this.entityStore?.syncFromScene();
@@ -200,12 +264,22 @@ export class SceneManagerService implements IService {
}
}
public async saveScene(): Promise<void> {
public async saveScene(force: boolean = false): Promise<void> {
if (!this.sceneState.currentScenePath) {
await this.saveSceneAs();
return;
}
// 检查文件是否被外部修改 | Check if file was modified externally
if (!force && await this.checkExternalModification()) {
// 发布事件让 UI 显示确认对话框 | Publish event for UI to show confirmation dialog
await this.messageHub.publish('scene:externalModification', {
path: this.sceneState.currentScenePath,
sceneName: this.sceneState.sceneName
});
return; // 等待用户确认 | Wait for user confirmation
}
try {
const scene = Core.scene as Scene | null;
if (!scene) {
@@ -219,8 +293,18 @@ export class SceneManagerService implements IService {
await this.fileAPI.saveProject(this.sceneState.currentScenePath, jsonData);
// 更新 mtime | Update mtime
if (this.fileAPI.getFileMtime) {
try {
this.sceneState.lastKnownMtime = await this.fileAPI.getFileMtime(this.sceneState.currentScenePath);
} catch (e) {
logger.warn('Failed to update file mtime after save:', e);
}
}
this.sceneState.isModified = false;
this.sceneState.isSaved = true;
this.sceneState.externallyModified = false;
await this.messageHub.publish('scene:saved', {
path: this.sceneState.currentScenePath
@@ -232,6 +316,89 @@ export class SceneManagerService implements IService {
}
}
/**
* 检查场景文件是否被外部修改
* Check if scene file was modified externally
*
* @returns true 如果文件被外部修改 | true if file was modified externally
*/
public async checkExternalModification(): Promise<boolean> {
const path = this.sceneState.currentScenePath;
const lastMtime = this.sceneState.lastKnownMtime;
if (!path || lastMtime === null || !this.fileAPI.getFileMtime) {
return false;
}
try {
const currentMtime = await this.fileAPI.getFileMtime(path);
const isModified = currentMtime > lastMtime;
if (isModified) {
this.sceneState.externallyModified = true;
logger.warn(`Scene file externally modified: ${path} (${lastMtime} -> ${currentMtime})`);
}
return isModified;
} catch (e) {
logger.warn('Failed to check file mtime:', e);
return false;
}
}
/**
* 重新加载当前场景(放弃本地更改)
* Reload current scene (discard local changes)
*/
public async reloadScene(): Promise<void> {
const path = this.sceneState.currentScenePath;
if (!path) {
logger.warn('No scene to reload');
return;
}
// 强制打开场景,绕过修改检查 | Force open scene, bypass modification check
const scene = Core.scene as Scene | null;
if (!scene) {
throw new Error('No active scene');
}
try {
const jsonData = await this.fileAPI.readFileContent(path);
const validation = SceneSerializer.validate(jsonData);
if (!validation.valid) {
throw new Error(`场景文件损坏: ${validation.errors?.join(', ')}`);
}
scene.isEditorMode = true;
scene.deserialize(jsonData, { strategy: 'replace' });
if (this.sceneResourceManager) {
await this.sceneResourceManager.loadSceneResources(scene);
}
// 更新 mtime | Update mtime
if (this.fileAPI.getFileMtime) {
try {
this.sceneState.lastKnownMtime = await this.fileAPI.getFileMtime(path);
} catch (e) {
logger.warn('Failed to update file mtime after reload:', e);
}
}
this.sceneState.isModified = false;
this.sceneState.isSaved = true;
this.sceneState.externallyModified = false;
this.entityStore?.syncFromScene();
await this.messageHub.publish('scene:reloaded', { path });
logger.info(`Scene reloaded: ${path}`);
} catch (error) {
logger.error('Failed to reload scene:', error);
throw error;
}
}
public async saveSceneAs(filePath?: string): Promise<void> {
let path: string | null | undefined = filePath;
if (!path) {
@@ -269,11 +436,23 @@ export class SceneManagerService implements IService {
const fileName = path.split(/[/\\]/).pop() || 'Untitled';
const sceneName = fileName.replace('.ecs', '');
// 获取文件修改时间 | Get file modification time
let mtime: number | null = null;
if (this.fileAPI.getFileMtime) {
try {
mtime = await this.fileAPI.getFileMtime(path);
} catch (e) {
logger.warn('Failed to get file mtime after save:', e);
}
}
this.sceneState = {
currentScenePath: path,
sceneName,
isModified: false,
isSaved: true
isSaved: true,
lastKnownMtime: mtime,
externallyModified: false
};
await this.messageHub.publish('scene:saved', { path });
@@ -405,11 +584,11 @@ export class SceneManagerService implements IService {
}
// 6. 获取组件注册表 | Get component registry
// ComponentRegistry.getAllComponentNames() 返回 Map<string, Function>
// GlobalComponentRegistry.getAllComponentNames() 返回 Map<string, Function>
// 需要转换为 Map<string, ComponentType>
const nameToType = ComponentRegistry.getAllComponentNames();
const nameToType = GlobalComponentRegistry.getAllComponentNames();
const componentRegistry = new Map<string, ComponentType>();
nameToType.forEach((type, name) => {
nameToType.forEach((type: Function, name: string) => {
componentRegistry.set(name, type as ComponentType);
});
@@ -471,7 +650,9 @@ export class SceneManagerService implements IService {
currentScenePath: null,
sceneName: `Prefab: ${prefabName}`,
isModified: false,
isSaved: true
isSaved: true,
lastKnownMtime: null,
externallyModified: false
};
// 11. 同步到 EntityStore | Sync to EntityStore
@@ -537,7 +718,9 @@ export class SceneManagerService implements IService {
currentScenePath: originalState.originalScenePath,
sceneName: originalState.originalSceneName,
isModified: originalState.originalSceneModified,
isSaved: !originalState.originalSceneModified
isSaved: !originalState.originalSceneModified,
lastKnownMtime: null,
externallyModified: false
};
// 5. 清除预制体编辑模式状态 | Clear prefab edit mode state

View File

@@ -71,16 +71,14 @@ export interface UserCodeCompileOptions {
sourceMap?: boolean;
/** Whether to minify output | 是否压缩输出 */
minify?: boolean;
/** Output format | 输出格式 */
/** Output format (default: 'esm') | 输出格式(默认:'esm'*/
format?: 'esm' | 'iife';
/**
* SDK modules for shim generation.
* 用于生成 shim 的 SDK 模块列表
* SDK modules information (reserved for future use).
* SDK 模块信息(保留供将来使用)
*
* If provided, shims will be created for these modules.
* Typically obtained from RuntimeResolver.getAvailableModules().
* 如果提供,将为这些模块创建 shim。
* 通常从 RuntimeResolver.getAvailableModules() 获取。
* Currently SDK is handled via external dependencies and global variable.
* 当前 SDK 通过外部依赖和全局变量处理。
*/
sdkModules?: SDKModuleInfo[];
}
@@ -382,6 +380,37 @@ export interface IUserCodeService {
* 检查是否正在监视。
*/
isWatching(): boolean;
/**
* Wait for user code to be ready (compiled and loaded).
* 等待用户代码准备就绪(已编译并加载)。
*
* This method is used to synchronize scene loading with user code compilation.
* Call this before loading a scene to ensure user components are registered.
* 此方法用于同步场景加载与用户代码编译。
* 在加载场景之前调用此方法以确保用户组件已注册。
*
* @returns Promise that resolves when user code is ready | 当用户代码就绪时解决的 Promise
*/
waitForReady(): Promise<void>;
/**
* Signal that user code is ready.
* 发出用户代码就绪信号。
*
* Called after user code compilation and registration is complete.
* 在用户代码编译和注册完成后调用。
*/
signalReady(): void;
/**
* Reset the ready state (for project switching).
* 重置就绪状态(用于项目切换)。
*
* Called when opening a new project to reset the ready promise.
* 打开新项目时调用以重置就绪 Promise。
*/
resetReady(): void;
}
import { EditorConfig } from '../../Config';

View File

@@ -11,7 +11,7 @@ import {
Injectable,
createLogger,
PlatformDetector,
ComponentRegistry as CoreComponentRegistry,
GlobalComponentRegistry as CoreComponentRegistry,
COMPONENT_TYPE_NAME,
SYSTEM_TYPE_NAME
} from '@esengine/ecs-framework';
@@ -82,9 +82,27 @@ export class UserCodeService implements IService, IUserCodeService {
*/
private _hotReloadCoordinator: HotReloadCoordinator;
/**
* 就绪状态 Promise
* Ready state promise
*/
private _readyPromise: Promise<void>;
private _readyResolve: (() => void) | undefined;
constructor(fileSystem: IFileSystem) {
this._fileSystem = fileSystem;
this._hotReloadCoordinator = new HotReloadCoordinator();
this._readyPromise = this._createReadyPromise();
}
/**
* Create a new ready promise.
* 创建新的就绪 Promise。
*/
private _createReadyPromise(): Promise<void> {
return new Promise<void>(resolve => {
this._readyResolve = resolve;
});
}
/**
@@ -190,28 +208,20 @@ export class UserCodeService implements IService, IUserCodeService {
const entryPath = `${outputDir}${sep}_entry_${options.target}.ts`;
await this._fileSystem.writeFile(entryPath, entryContent);
// Create shim files for framework dependencies | 创建框架依赖的 shim 文件
// 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;
// Get external dependencies | 获取外部依赖
// SDK marked as external, resolved from global variable at runtime
// SDK 标记为外部依赖,运行时从全局变量解析
const external = this._getExternalDependencies(options.target, options.sdkModules);
// Compile using esbuild (via Tauri command or direct) | 使用 esbuild 编译
// Use IIFE format to avoid ES module import issues in Tauri
// 使用 IIFE 格式以避免 Tauri 中的 ES 模块导入问题
// Use ESM format for dynamic import() loading | 使用 ESM 格式以支持动态 import() 加载
const compileResult = await this._runEsbuild({
entryPath,
outputPath,
format: 'iife', // Always use IIFE for Tauri compatibility | 始终使用 IIFE 以兼容 Tauri
globalName,
format: 'esm', // ESM for standard dynamic import() | ESM 用于标准动态 import()
sourceMap: options.sourceMap ?? true,
minify: options.minify ?? false,
external: [], // Don't use external, use alias instead | 不使用 external使用 alias
alias,
external,
projectRoot: options.projectPath
});
@@ -259,6 +269,14 @@ export class UserCodeService implements IService, IUserCodeService {
* Load compiled user code module.
* 加载编译后的用户代码模块。
*
* Uses Blob URL for ESM dynamic import in Tauri environment.
* 在 Tauri 环境中使用 Blob URL 进行 ESM 动态导入。
*
* Note: Browser's import() only supports http://, https://, and blob:// protocols.
* Custom protocols like project:// are not supported for ESM imports.
* 注意:浏览器的 import() 只支持 http://、https:// 和 blob:// 协议。
* 自定义协议如 project:// 不支持 ESM 导入。
*
* @param modulePath - Path to compiled JS file | 编译后的 JS 文件路径
* @param target - Target environment | 目标环境
* @returns Loaded module | 加载的模块
@@ -268,20 +286,23 @@ export class UserCodeService implements IService, IUserCodeService {
let moduleExports: Record<string, any>;
if (PlatformDetector.isTauriEnvironment()) {
// In Tauri, read file content and execute via script tag
// Tauri 中,读取文件内容并通过 script 标签执行
// This avoids CORS and module resolution issues
// 这避免了 CORS 和模块解析问题
// Read file content via Tauri and load via Blob URL
// 通过 Tauri 读取文件内容并通过 Blob URL 加载
// Browser's import() doesn't support custom protocols like project://
// 浏览器的 import() 不支持自定义协议如 project://
const { invoke } = await import('@tauri-apps/api/core');
const content = await invoke<string>('read_file_content', {
path: modulePath
});
logger.debug(`Loading module via script injection`, { originalPath: modulePath });
logger.debug(`Loading ESM module via Blob URL`, {
path: modulePath,
contentLength: content.length
});
// Execute module code and capture exports | 执行模块代码并捕获导出
moduleExports = await this._executeModuleCode(content, target);
// Load ESM via Blob URL | 通过 Blob URL 加载 ESM
moduleExports = await this._loadESMFromContent(content);
} else {
// Fallback to file:// for non-Tauri environments
// 非 Tauri 环境使用 file://
@@ -924,6 +945,35 @@ export class UserCodeService implements IService, IUserCodeService {
return this._watching;
}
/**
* Wait for user code to be ready (compiled and loaded).
* 等待用户代码准备就绪(已编译并加载)。
*
* @returns Promise that resolves when user code is ready | 当用户代码就绪时解决的 Promise
*/
waitForReady(): Promise<void> {
return this._readyPromise;
}
/**
* Signal that user code is ready.
* 发出用户代码就绪信号。
*/
signalReady(): void {
if (this._readyResolve) {
this._readyResolve();
this._readyResolve = undefined;
}
}
/**
* Reset the ready state (for project switching).
* 重置就绪状态(用于项目切换)。
*/
resetReady(): void {
this._readyPromise = this._createReadyPromise();
}
/**
* Dispose service resources.
* 释放服务资源。
@@ -1058,44 +1108,6 @@ export class UserCodeService implements IService, IUserCodeService {
return lines.join('\n');
}
/**
* Create shim file that maps SDK global variable to module import.
* 创建将 SDK 全局变量映射到模块导入的 shim 文件。
*
* This is used for IIFE format to resolve external dependencies.
* Creates a single shim for @esengine/sdk.
* 这用于 IIFE 格式解析外部依赖。
* 只创建一个 @esengine/sdk 的 shim。
*
* @param outputDir - Output directory | 输出目录
* @param _sdkModules - Deprecated, not used | 已废弃,不再使用
* @returns Mapping from package name to shim path | 包名到 shim 路径的映射
*/
private async _createDependencyShims(
outputDir: string,
_sdkModules?: SDKModuleInfo[]
): Promise<Record<string, string>> {
const sep = outputDir.includes('\\') ? '\\' : '/';
const sdkGlobalName = EditorConfig.globals.sdk;
// 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.
* 获取不应打包的外部依赖。
@@ -1122,16 +1134,24 @@ module.exports = (typeof window !== 'undefined' && window.${sdkGlobalName}) || {
*
* Uses Tauri command to invoke esbuild CLI.
* 使用 Tauri 命令调用 esbuild CLI。
*
* @param options - Compilation options | 编译选项
* @returns Compilation result | 编译结果
*/
private async _runEsbuild(options: {
/** Entry file path | 入口文件路径 */
entryPath: string;
/** Output file path | 输出文件路径 */
outputPath: string;
/** Output format (ESM for dynamic import) | 输出格式ESM 用于动态导入)*/
format: 'esm' | 'iife';
globalName?: string;
/** Generate source maps | 生成源码映射 */
sourceMap: boolean;
/** Minify output | 压缩输出 */
minify: boolean;
/** External dependencies (not bundled) | 外部依赖(不打包)*/
external: string[];
alias?: Record<string, string>;
/** Project root for resolving paths | 项目根路径用于解析路径 */
projectRoot: string;
}): Promise<{ success: boolean; errors: CompileError[] }> {
try {
@@ -1143,13 +1163,9 @@ module.exports = (typeof window !== 'undefined' && window.${sdkGlobalName}) || {
entry: options.entryPath,
output: options.outputPath,
format: options.format,
aliasCount: options.alias ? Object.keys(options.alias).length : 0
external: options.external
});
if (options.alias) {
logger.debug('esbuild alias mappings:', options.alias);
}
// Use Tauri command | 使用 Tauri 命令
const { invoke } = await import('@tauri-apps/api/core');
@@ -1167,11 +1183,9 @@ module.exports = (typeof window !== 'undefined' && window.${sdkGlobalName}) || {
entryPath: options.entryPath,
outputPath: options.outputPath,
format: options.format,
globalName: options.globalName,
sourceMap: options.sourceMap,
minify: options.minify,
external: options.external,
alias: options.alias,
projectRoot: options.projectRoot
}
});
@@ -1206,52 +1220,30 @@ module.exports = (typeof window !== 'undefined' && window.${sdkGlobalName}) || {
}
/**
* Execute compiled module code and return exports.
* 执行编译后的模块代码并返回导出
* Load ESM module from JavaScript content string.
* 从 JavaScript 内容字符串加载 ESM 模块
*
* The code should be in IIFE format that sets a global variable.
* 代码应该是设置全局变量的 IIFE 格式
* Uses Blob URL to enable dynamic import() of ESM content.
* 使用 Blob URL 实现 ESM 内容的动态 import()
*
* @param code - Compiled JavaScript code | 编译后的 JavaScript 代码
* @param target - Target environment | 目标环境
* @param content - JavaScript module content (ESM format) | JavaScript 模块内容ESM 格式)
* @returns Module exports | 模块导出
*/
private async _executeModuleCode(
code: string,
target: UserCodeTarget
): Promise<Record<string, any>> {
// Determine global name based on target | 根据目标确定全局名称
const globalName = target === UserCodeTarget.Runtime
? EditorConfig.globals.userRuntimeExports
: EditorConfig.globals.userEditorExports;
// Clear any previous exports | 清除之前的导出
(window as any)[globalName] = undefined;
private async _loadESMFromContent(content: string): Promise<Record<string, any>> {
// Create Blob URL for ESM module | 为 ESM 模块创建 Blob URL
const blob = new Blob([content], { type: 'application/javascript' });
const blobUrl = URL.createObjectURL(blob);
try {
// esbuild generates: var __USER_RUNTIME_EXPORTS__ = (() => {...})();
// When executed via new Function(), var declarations stay in function scope
// We need to replace "var globalName" with "window.globalName" to expose it
// esbuild 生成: var __USER_RUNTIME_EXPORTS__ = (() => {...})();
// 通过 new Function() 执行时var 声明在函数作用域内
// 需要替换 "var globalName" 为 "window.globalName" 以暴露到全局
const modifiedCode = code.replace(
new RegExp(`^"use strict";\\s*var ${globalName}`, 'm'),
`"use strict";\nwindow.${globalName}`
);
// Dynamic import the ESM module | 动态导入 ESM 模块
const moduleExports = await import(/* @vite-ignore */ blobUrl);
// Execute the IIFE code | 执行 IIFE 代码
// eslint-disable-next-line no-new-func
const executeScript = new Function(modifiedCode);
executeScript();
// Get exports from global | 从全局获取导出
const exports = (window as any)[globalName] || {};
return exports;
} catch (error) {
logger.error('Failed to execute user code | 执行用户代码失败:', error);
throw error;
// Return all exports | 返回所有导出
return { ...moduleExports };
} finally {
// Always revoke Blob URL to prevent memory leaks
// 始终撤销 Blob URL 以防止内存泄漏
URL.revokeObjectURL(blobUrl);
}
}

View File

@@ -43,9 +43,10 @@
* ↓
* [UserCodeService.scan()] - Discovers all scripts
* ↓
* [UserCodeService.compile()] - Compiles to JS using esbuild
* [UserCodeService.compile()] - Compiles to ESM using esbuild
* (@esengine/sdk marked as external)
* ↓
* [UserCodeService.load()] - Loads compiled module
* [UserCodeService.load()] - Loads via project:// protocol + import()
* ↓
* [registerComponents()] - Registers with ECS runtime
* [registerEditorExtensions()] - Registers inspectors/gizmos
@@ -53,6 +54,16 @@
* [UserCodeService.watch()] - Hot reload on file changes
* ```
*
* # Architecture | 架构
*
* - **Compilation**: ESM format with `external: ['@esengine/sdk']`
* - **Loading**: Reads file via Tauri, loads via Blob URL + import()
* - **Runtime**: SDK accessed via `window.__ESENGINE_SDK__` global
* - **Hot Reload**: File watching via Rust backend + Tauri events
*
* Note: Browser's import() only supports http/https/blob protocols.
* Custom protocols like project:// are not supported for ESM imports.
*
* # Example User Component | 用户组件示例
*
* ```typescript

View File

@@ -61,4 +61,13 @@ export interface IFileAPI {
* @returns 路径是否存在
*/
pathExists(path: string): Promise<boolean>;
/**
* 获取文件修改时间
* Get file modification time
*
* @param path 文件路径 | File path
* @returns 文件修改时间(毫秒时间戳)| File modification time (milliseconds timestamp)
*/
getFileMtime?(path: string): Promise<number>;
}