feat(editor): 实现用户脚本编译加载和自动重编译 (#273)

This commit is contained in:
YHH
2025-12-04 19:32:51 +08:00
committed by GitHub
parent 3d16bbdc64
commit 0d9bab910e
17 changed files with 951 additions and 127 deletions

View File

@@ -12,7 +12,7 @@
* Uses .meta files to persistently store each asset's GUID.
*/
import { Core, createLogger } from '@esengine/ecs-framework';
import { Core, createLogger, PlatformDetector } from '@esengine/ecs-framework';
import { MessageHub } from './MessageHub';
import {
AssetMetaManager,
@@ -215,6 +215,9 @@ export class AssetRegistryService {
/** Asset meta manager for .meta file management */
private _metaManager: AssetMetaManager;
/** Tauri event unlisten function | Tauri 事件取消监听函数 */
private _eventUnlisten: (() => void) | undefined;
/** Manifest file name */
static readonly MANIFEST_FILE = 'asset-manifest.json';
/** Current manifest version */
@@ -311,6 +314,10 @@ export class AssetRegistryService {
// Save updated manifest
await this._saveManifest();
// Subscribe to file change events (Tauri only)
// 订阅文件变化事件(仅 Tauri 环境)
await this._subscribeToFileChanges();
logger.info(`Project assets loaded: ${this._database.getStatistics().totalAssets} assets`);
// Publish event
@@ -320,10 +327,77 @@ export class AssetRegistryService {
});
}
/**
* Subscribe to file change events from Tauri backend
* 订阅来自 Tauri 后端的文件变化事件
*/
private async _subscribeToFileChanges(): Promise<void> {
// Only in Tauri environment
// 仅在 Tauri 环境中
if (!PlatformDetector.isTauriEnvironment()) {
return;
}
try {
const { listen } = await import('@tauri-apps/api/event');
// Listen to user-code:file-changed event
// 监听 user-code:file-changed 事件
this._eventUnlisten = await listen<{
changeType: string;
paths: string[];
}>('user-code:file-changed', async (event) => {
const { changeType, paths } = event.payload;
logger.debug('File change event received | 收到文件变化事件', { changeType, paths });
// Handle file creation - register new assets and generate .meta
// 处理文件创建 - 注册新资产并生成 .meta
if (changeType === 'create' || changeType === 'modify') {
for (const absolutePath of paths) {
// Skip .meta files
if (absolutePath.endsWith('.meta')) continue;
// Register or refresh the asset
await this.registerAsset(absolutePath);
}
} else if (changeType === 'remove') {
for (const absolutePath of paths) {
// Skip .meta files
if (absolutePath.endsWith('.meta')) continue;
// Unregister the asset
await this.unregisterAsset(absolutePath);
}
}
});
logger.info('Subscribed to file change events | 已订阅文件变化事件');
} catch (error) {
logger.warn('Failed to subscribe to file change events | 订阅文件变化事件失败:', error);
}
}
/**
* Unsubscribe from file change events
* 取消订阅文件变化事件
*/
private _unsubscribeFromFileChanges(): void {
if (this._eventUnlisten) {
this._eventUnlisten();
this._eventUnlisten = undefined;
logger.debug('Unsubscribed from file change events | 已取消订阅文件变化事件');
}
}
/**
* Unload current project
*/
unloadProject(): void {
// Unsubscribe from file change events
// 取消订阅文件变化事件
this._unsubscribeFromFileChanges();
this._projectPath = null;
this._manifest = null;
this._database.clear();

View File

@@ -130,13 +130,10 @@ export class ProjectService implements IService {
const assetsPath = `${projectPath}${sep}assets`;
await this.fileAPI.createDirectory(assetsPath);
// Create types folder for type definitions
// 创建类型定义文件夹
const typesPath = `${projectPath}${sep}types`;
await this.fileAPI.createDirectory(typesPath);
// Create tsconfig.json for TypeScript support
// 创建 tsconfig.json 用于 TypeScript 支持
// Create tsconfig.json for runtime scripts (components, systems)
// 创建运行时脚本的 tsconfig.json组件、系统等
// Note: paths will be populated by update_project_tsconfig when project is opened
// 注意paths 会在项目打开时由 update_project_tsconfig 填充
const tsConfig = {
compilerOptions: {
target: 'ES2020',
@@ -149,21 +146,40 @@ export class ProjectService implements IService {
forceConsistentCasingInFileNames: true,
experimentalDecorators: true,
emitDecoratorMetadata: true,
noEmit: true,
// Reference local type definitions
// 引用本地类型定义文件
typeRoots: ['./types'],
paths: {
'@esengine/ecs-framework': ['./types/ecs-framework.d.ts'],
'@esengine/engine-core': ['./types/engine-core.d.ts']
}
noEmit: true
// paths will be added by editor when project is opened
// paths 会在编辑器打开项目时添加
},
include: ['scripts/**/*.ts'],
exclude: ['.esengine']
exclude: ['scripts/editor/**/*.ts', '.esengine']
};
const tsConfigPath = `${projectPath}${sep}tsconfig.json`;
await this.fileAPI.writeFileContent(tsConfigPath, JSON.stringify(tsConfig, null, 2));
// Create tsconfig.editor.json for editor extension scripts
// 创建编辑器扩展脚本的 tsconfig.editor.json
const tsConfigEditor = {
compilerOptions: {
target: 'ES2020',
module: 'ESNext',
moduleResolution: 'bundler',
lib: ['ES2020', 'DOM'],
strict: true,
esModuleInterop: true,
skipLibCheck: true,
forceConsistentCasingInFileNames: true,
experimentalDecorators: true,
emitDecoratorMetadata: true,
noEmit: true
// paths will be added by editor when project is opened
// paths 会在编辑器打开项目时添加
},
include: ['scripts/editor/**/*.ts'],
exclude: ['.esengine']
};
const tsConfigEditorPath = `${projectPath}${sep}tsconfig.editor.json`;
await this.fileAPI.writeFileContent(tsConfigEditorPath, JSON.stringify(tsConfigEditor, null, 2));
await this.messageHub.publish('project:created', {
path: projectPath
});

View File

@@ -215,8 +215,9 @@ export interface IUserCodeService {
* - Classes extending System
*
* @param module - User code module | 用户代码模块
* @param componentRegistry - Optional ComponentRegistry to register components | 可选的 ComponentRegistry 用于注册组件
*/
registerComponents(module: UserCodeModule): void;
registerComponents(module: UserCodeModule, componentRegistry?: any): void;
/**
* Register editor extensions from user module.

View File

@@ -7,7 +7,7 @@
*/
import type { IService } from '@esengine/ecs-framework';
import { Injectable, createLogger } from '@esengine/ecs-framework';
import { Injectable, createLogger, PlatformDetector } from '@esengine/ecs-framework';
import type {
IUserCodeService,
UserScriptInfo,
@@ -110,6 +110,9 @@ export class UserCodeService implements IService, IUserCodeService {
const errors: CompileError[] = [];
const warnings: CompileError[] = [];
// Store project path for later use in load() | 存储项目路径供 load() 使用
this._currentProjectPath = options.projectPath;
const sep = options.projectPath.includes('\\') ? '\\' : '/';
const scriptsDir = `${options.projectPath}${sep}${SCRIPTS_DIR}`;
const outputDir = options.outputDir || `${options.projectPath}${sep}${USER_CODE_OUTPUT_DIR}`;
@@ -146,14 +149,35 @@ 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 文件
await this._createDependencyShims(outputDir, options.target);
// Determine global name for IIFE output | 确定 IIFE 输出的全局名称
const globalName = options.target === UserCodeTarget.Runtime
? '__USER_RUNTIME_EXPORTS__'
: '__USER_EDITOR_EXPORTS__';
// 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 模块导入问题
const compileResult = await this._runEsbuild({
entryPath,
outputPath,
format: options.format || 'esm',
format: 'iife', // Always use IIFE for Tauri compatibility | 始终使用 IIFE 以兼容 Tauri
globalName,
sourceMap: options.sourceMap ?? true,
minify: options.minify ?? false,
external: this._getExternalDependencies(options.target),
external: [], // Don't use external, use alias instead | 不使用 external使用 alias
alias,
projectRoot: options.projectPath
});
@@ -207,12 +231,30 @@ export class UserCodeService implements IService, IUserCodeService {
*/
async load(modulePath: string, target: UserCodeTarget): Promise<UserCodeModule> {
try {
// Add cache-busting query parameter for hot reload | 添加缓存破坏参数用于热更新
const cacheBuster = `?t=${Date.now()}`;
const moduleUrl = `file://${modulePath}${cacheBuster}`;
let moduleExports: Record<string, any>;
// Dynamic import the module | 动态导入模块
const moduleExports = await import(/* @vite-ignore */ moduleUrl);
if (PlatformDetector.isTauriEnvironment()) {
// In Tauri, read file content and execute via script tag
// 在 Tauri 中,读取文件内容并通过 script 标签执行
// This avoids CORS and module resolution issues
// 这避免了 CORS 和模块解析问题
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 });
// Execute module code and capture exports | 执行模块代码并捕获导出
moduleExports = await this._executeModuleCode(content, target);
} else {
// Fallback to file:// for non-Tauri environments
// 非 Tauri 环境使用 file://
const cacheBuster = `?t=${Date.now()}`;
const moduleUrl = `file://${modulePath}${cacheBuster}`;
moduleExports = await import(/* @vite-ignore */ moduleUrl);
}
const module: UserCodeModule = {
id: `user-${target}-${Date.now()}`,
@@ -273,7 +315,7 @@ export class UserCodeService implements IService, IUserCodeService {
*
* @param module - User code module | 用户代码模块
*/
registerComponents(module: UserCodeModule): void {
registerComponents(module: UserCodeModule, componentRegistry?: any): void {
if (module.target !== UserCodeTarget.Runtime) {
logger.warn('Cannot register components from editor module | 无法从编辑器模块注册组件');
return;
@@ -289,10 +331,24 @@ export class UserCodeService implements IService, IUserCodeService {
// Check if it's a Component subclass | 检查是否是 Component 子类
if (this._isComponentClass(exported)) {
// Register with ComponentRegistry | 注册到 ComponentRegistry
// Note: Actual registration depends on runtime context
// 注意:实际注册取决于运行时上下文
logger.debug(`Found component: ${name} | 发现组件: ${name}`);
// Register with ComponentRegistry if provided | 如果提供了 ComponentRegistry 则注册
// ComponentRegistry expects ComponentTypeInfo object, not the class directly
// ComponentRegistry 期望 ComponentTypeInfo 对象,而不是直接传入类
if (componentRegistry && typeof componentRegistry.register === 'function') {
try {
componentRegistry.register({
name: name,
type: exported,
category: 'User', // User-defined components | 用户自定义组件
description: `User component: ${name}`
});
} catch (err) {
logger.warn(`Failed to register component ${name} | 注册组件 ${name} 失败:`, err);
}
}
componentCount++;
}
@@ -373,7 +429,7 @@ export class UserCodeService implements IService, IUserCodeService {
try {
// Check if we're in Tauri environment | 检查是否在 Tauri 环境
if (typeof window !== 'undefined' && '__TAURI__' in window) {
if (PlatformDetector.isTauriEnvironment()) {
const { invoke } = await import('@tauri-apps/api/core');
const { listen } = await import('@tauri-apps/api/event');
@@ -461,7 +517,7 @@ export class UserCodeService implements IService, IUserCodeService {
}
// Stop backend file watcher | 停止后端文件监视器
if (typeof window !== 'undefined' && '__TAURI__' in window) {
if (PlatformDetector.isTauriEnvironment()) {
const { invoke } = await import('@tauri-apps/api/core');
await invoke('stop_watch_scripts', {
projectPath: this._currentProjectPath
@@ -577,6 +633,17 @@ export class UserCodeService implements IService, IUserCodeService {
/**
* Build entry point content that re-exports all user scripts.
* 构建重新导出所有用户脚本的入口点内容。
*
* Entry file is in: {projectPath}/.esengine/compiled/_entry_runtime.ts
* Scripts are in: {projectPath}/scripts/
* So the relative path from entry to scripts is: ../../scripts/
*
* For IIFE format, we inject shims that map global variables to module imports.
* This allows user code to use `import { Component } from '@esengine/ecs-framework'`
* while actually accessing `window.__ESENGINE_FRAMEWORK__`.
* 对于 IIFE 格式,我们注入 shim 将全局变量映射到模块导入。
* 这使用户代码可以使用 `import { Component } from '@esengine/ecs-framework'`
* 实际上访问的是 `window.__ESENGINE_FRAMEWORK__`。
*/
private _buildEntryPoint(
scripts: UserScriptInfo[],
@@ -589,22 +656,60 @@ export class UserCodeService implements IService, IUserCodeService {
''
];
// Entry file is in .esengine/compiled/, need to go up 2 levels to reach project root
// 入口文件在 .esengine/compiled/ 目录,需要上升 2 级到达项目根目录
const relativePrefix = `../../${SCRIPTS_DIR}`;
for (const script of scripts) {
// Convert absolute path to relative import | 将绝对路径转换为相对导入
const relativePath = script.relativePath.replace(/\\/g, '/').replace(/\.tsx?$/, '');
if (script.exports.length > 0) {
lines.push(`export { ${script.exports.join(', ')} } from './${SCRIPTS_DIR}/${relativePath}';`);
lines.push(`export { ${script.exports.join(', ')} } from '${relativePrefix}/${relativePath}';`);
} else {
// Re-export everything if we couldn't detect specific exports
// 如果无法检测到具体导出,则重新导出所有内容
lines.push(`export * from './${SCRIPTS_DIR}/${relativePath}';`);
lines.push(`export * from '${relativePrefix}/${relativePath}';`);
}
}
return lines.join('\n');
}
/**
* Create shim files that map global variables to module imports.
* 创建将全局变量映射到模块导入的 shim 文件。
*
* This is used for IIFE format to resolve external dependencies.
* The shim exports the global __ESENGINE__.ecsFramework which is set by PluginSDKRegistry.
* 这用于 IIFE 格式解析外部依赖。
* shim 导出全局的 __ESENGINE__.ecsFramework由 PluginSDKRegistry 设置。
*
* @param outputDir - Output directory | 输出目录
* @param target - Target environment | 目标环境
* @returns Array of shim file paths | shim 文件路径数组
*/
private async _createDependencyShims(
outputDir: string,
target: UserCodeTarget
): Promise<string[]> {
const sep = outputDir.includes('\\') ? '\\' : '/';
const shimPaths: string[] = [];
// Create shim for @esengine/ecs-framework | 为 @esengine/ecs-framework 创建 shim
// This uses window.__ESENGINE__.ecsFramework set by PluginSDKRegistry
// 这使用 PluginSDKRegistry 设置的 window.__ESENGINE__.ecsFramework
const ecsShimPath = `${outputDir}${sep}_shim_ecs_framework.js`;
const ecsShimContent = `// Shim for @esengine/ecs-framework
// Maps to window.__ESENGINE__.ecsFramework set by PluginSDKRegistry
module.exports = (typeof window !== 'undefined' && window.__ESENGINE__ && window.__ESENGINE__.ecsFramework) || {};
`;
await this._fileSystem.writeFile(ecsShimPath, ecsShimContent);
shimPaths.push(ecsShimPath);
return shimPaths;
}
/**
* Get external dependencies that should not be bundled.
* 获取不应打包的外部依赖。
@@ -640,14 +745,16 @@ export class UserCodeService implements IService, IUserCodeService {
entryPath: string;
outputPath: string;
format: 'esm' | 'iife';
globalName?: string;
sourceMap: boolean;
minify: boolean;
external: string[];
alias?: Record<string, string>;
projectRoot: string;
}): Promise<{ success: boolean; errors: CompileError[] }> {
try {
// Check if we're in Tauri environment | 检查是否在 Tauri 环境
if (typeof window !== 'undefined' && '__TAURI__' in window) {
if (PlatformDetector.isTauriEnvironment()) {
// Use Tauri command | 使用 Tauri 命令
const { invoke } = await import('@tauri-apps/api/core');
@@ -665,9 +772,11 @@ export class UserCodeService implements IService, IUserCodeService {
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
}
});
@@ -701,6 +810,56 @@ export class UserCodeService implements IService, IUserCodeService {
}
}
/**
* Execute compiled module code and return exports.
* 执行编译后的模块代码并返回导出。
*
* The code should be in IIFE format that sets a global variable.
* 代码应该是设置全局变量的 IIFE 格式。
*
* @param code - Compiled JavaScript code | 编译后的 JavaScript 代码
* @param target - Target environment | 目标环境
* @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
? '__USER_RUNTIME_EXPORTS__'
: '__USER_EDITOR_EXPORTS__';
// Clear any previous exports | 清除之前的导出
(window as any)[globalName] = undefined;
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}`
);
// 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;
}
}
/**
* Ensure directory exists, create if not.
* 确保目录存在,如果不存在则创建。
@@ -719,17 +878,29 @@ export class UserCodeService implements IService, IUserCodeService {
/**
* Check if a class extends Component.
* 检查类是否继承自 Component。
*
* Uses the actual Component class from the global framework to check inheritance.
* 使用全局框架中的实际 Component 类来检查继承关系。
*/
private _isComponentClass(cls: any): boolean {
// Check prototype chain for Component | 检查原型链中是否有 Component
let proto = cls.prototype;
while (proto) {
if (proto.constructor?.name === 'Component') {
return true;
}
proto = Object.getPrototypeOf(proto);
// Get Component class from global framework | 从全局框架获取 Component
const framework = (window as any).__ESENGINE__?.ecsFramework;
if (!framework?.Component) {
return false;
}
// Use instanceof or prototype chain check | 使用 instanceof 或原型链检查
try {
const ComponentClass = framework.Component;
// Check if cls.prototype is an instance of Component
// 检查 cls.prototype 是否是 Component 的实例
return cls.prototype instanceof ComponentClass ||
ComponentClass.prototype.isPrototypeOf(cls.prototype);
} catch {
return false;
}
return false;
}
/**