diff --git a/packages/editor-core/package.json b/packages/editor-core/package.json index 8e7a0a61..587e0bfe 100644 --- a/packages/editor-core/package.json +++ b/packages/editor-core/package.json @@ -41,6 +41,7 @@ "devDependencies": { "@esengine/ecs-framework": "workspace:*", "@esengine/asset-system": "workspace:*", + "@esengine/asset-system-editor": "workspace:*", "@esengine/engine-core": "workspace:*", "@babel/core": "^7.28.3", "@babel/plugin-transform-nullish-coalescing-operator": "^7.27.1", diff --git a/packages/editor-core/src/Plugin/IPluginLoader.ts b/packages/editor-core/src/Plugin/IPluginLoader.ts index ea386662..2e7dcb84 100644 --- a/packages/editor-core/src/Plugin/IPluginLoader.ts +++ b/packages/editor-core/src/Plugin/IPluginLoader.ts @@ -10,15 +10,14 @@ import type { ServiceContainer } from '@esengine/ecs-framework'; // 从 PluginDescriptor 重新导出(来源于 engine-core) export type { - PluginCategory, LoadingPhase, - ModuleType, - ModuleDescriptor, - PluginDependency, - PluginDescriptor, SystemContext, IRuntimeModule, - IPlugin + IPlugin, + ModuleManifest, + ModuleCategory, + ModulePlatform, + ModuleExports } from './PluginDescriptor'; // ============================================================================ diff --git a/packages/editor-core/src/Plugin/PluginDescriptor.ts b/packages/editor-core/src/Plugin/PluginDescriptor.ts index 37a2308d..fa7d7ab0 100644 --- a/packages/editor-core/src/Plugin/PluginDescriptor.ts +++ b/packages/editor-core/src/Plugin/PluginDescriptor.ts @@ -1,22 +1,21 @@ /** - * 插件描述符类型 - * Plugin descriptor types + * 插件/模块类型定义 + * Plugin/Module type definitions * - * 从 @esengine/engine-core 重新导出基础类型,并添加编辑器专用类型。 - * Re-export base types from @esengine/engine-core, and add editor-specific types. + * 从 @esengine/engine-core 重新导出基础类型。 + * Re-export base types from @esengine/engine-core. */ -// 从 engine-core 重新导出所有插件相关类型 +// 从 engine-core 重新导出所有类型 export type { - PluginCategory, LoadingPhase, - ModuleType, - ModuleDescriptor, - PluginDependency, - PluginDescriptor, SystemContext, IRuntimeModule, - IPlugin + IPlugin, + ModuleManifest, + ModuleCategory, + ModulePlatform, + ModuleExports } from '@esengine/engine-core'; /** diff --git a/packages/editor-core/src/Plugin/PluginManager.ts b/packages/editor-core/src/Plugin/PluginManager.ts index 0b2e05cc..5211c243 100644 --- a/packages/editor-core/src/Plugin/PluginManager.ts +++ b/packages/editor-core/src/Plugin/PluginManager.ts @@ -6,11 +6,10 @@ import { createLogger, ComponentRegistry } from '@esengine/ecs-framework'; import type { IScene, ServiceContainer, IService } from '@esengine/ecs-framework'; import type { - PluginDescriptor, - PluginState, - PluginCategory, - LoadingPhase, - IPlugin + ModuleManifest, + IPlugin, + ModuleCategory, + PluginState } from './PluginDescriptor'; import type { SystemContext, @@ -21,6 +20,7 @@ import { ComponentActionRegistry } from '../Services/ComponentActionRegistry'; import { FileActionRegistry } from '../Services/FileActionRegistry'; import { UIRegistry } from '../Services/UIRegistry'; import { MessageHub } from '../Services/MessageHub'; +import { moduleRegistry } from '../Services/Module/ModuleRegistry'; const logger = createLogger('PluginManager'); @@ -31,24 +31,29 @@ const logger = createLogger('PluginManager'); export const IPluginManager = Symbol.for('IPluginManager'); /** - * 标准化后的插件描述符(所有字段都有值) - * Normalized plugin descriptor (all fields have values) + * 标准化后的模块清单(所有字段都有值) + * Normalized module manifest (all fields have values) */ -export interface NormalizedPluginDescriptor { +export interface NormalizedManifest { id: string; name: string; + displayName: string; version: string; description: string; - category: PluginCategory; + category: ModuleCategory; tags: string[]; icon?: string; - enabledByDefault: boolean; + defaultEnabled: boolean; canContainContent: boolean; - isEnginePlugin: boolean; + isEngineModule: boolean; isCore: boolean; - modules: Array<{ name: string; type: 'runtime' | 'editor'; loadingPhase: LoadingPhase }>; - dependencies: Array<{ id: string; version?: string; optional?: boolean }>; + dependencies: string[]; + exports: { components?: string[]; systems?: string[]; loaders?: string[]; other?: string[] }; platforms: ('web' | 'desktop' | 'mobile')[]; + editorPackage?: string; + jsSize?: number; + wasmSize?: number; + requiresWasm?: boolean; } /** @@ -56,7 +61,7 @@ export interface NormalizedPluginDescriptor { * Normalized plugin (internal use) */ export interface NormalizedPlugin { - descriptor: NormalizedPluginDescriptor; + manifest: NormalizedManifest; runtimeModule?: IPlugin['runtimeModule']; editorModule?: IEditorModuleLoader; } @@ -112,18 +117,6 @@ export interface PluginConfig { enabledPlugins: string[]; } -/** - * 加载阶段顺序 - * Loading phase order - */ -const LOADING_PHASE_ORDER: LoadingPhase[] = [ - 'earliest', - 'preDefault', - 'default', - 'postDefault', - 'postEngine' -]; - /** * 统一插件管理器 * Unified Plugin Manager @@ -168,31 +161,49 @@ export class PluginManager implements IService { } /** - * 标准化插件描述符,填充默认值 - * Normalize plugin descriptor, fill in defaults + * 解析依赖 ID 为完整的插件 ID + * Resolve dependency ID to full plugin ID + * + * 支持两种格式: + * - 短 ID: "core" -> "@esengine/core" + * - 完整 ID: "@esengine/core" -> "@esengine/core" + */ + private resolveDependencyId(depId: string): string { + // 如果已经是完整 ID,直接返回 + if (depId.startsWith('@')) { + return depId; + } + // 短 ID 转换为完整 ID + return `@esengine/${depId}`; + } + + /** + * 标准化模块清单,填充默认值 + * Normalize module manifest, fill in defaults */ private normalizePlugin(input: IPlugin): NormalizedPlugin { - const d = input.descriptor; + const m = input.manifest; return { - descriptor: { - id: d.id, - name: d.name, - version: d.version, - description: d.description ?? '', - category: d.category ?? 'tools', - tags: d.tags ?? [], - icon: d.icon, - enabledByDefault: d.enabledByDefault ?? false, - canContainContent: d.canContainContent ?? false, - isEnginePlugin: d.isEnginePlugin ?? true, - isCore: d.isCore ?? false, - modules: (d.modules ?? [{ name: 'Runtime', type: 'runtime' as const, loadingPhase: 'default' as const }]).map((m: { name: string; type: 'runtime' | 'editor'; loadingPhase?: LoadingPhase }) => ({ - name: m.name, - type: m.type, - loadingPhase: m.loadingPhase ?? 'default' as LoadingPhase - })), - dependencies: d.dependencies ?? [], - platforms: d.platforms ?? ['web', 'desktop'] + manifest: { + id: m.id, + name: m.name, + displayName: m.displayName, + version: m.version, + description: m.description ?? '', + category: m.category ?? 'Other', + tags: m.tags ?? [], + icon: m.icon, + defaultEnabled: m.defaultEnabled ?? false, + canContainContent: m.canContainContent ?? false, + isEngineModule: m.isEngineModule ?? true, + isCore: m.isCore ?? false, + dependencies: m.dependencies ?? [], + exports: m.exports ?? {}, + platforms: m.platforms ?? ['web', 'desktop'], + editorPackage: m.editorPackage, + jsSize: m.jsSize, + wasmSize: m.wasmSize, + requiresWasm: m.requiresWasm }, runtimeModule: input.runtimeModule, editorModule: input.editorModule as IEditorModuleLoader | undefined @@ -212,15 +223,15 @@ export class PluginManager implements IService { return; } - if (!plugin.descriptor) { - logger.error('Cannot register plugin: descriptor is null or undefined', plugin); + if (!plugin.manifest) { + logger.error('Cannot register plugin: manifest is null or undefined', plugin); return; } - const { id } = plugin.descriptor; + const { id } = plugin.manifest; if (!id) { - logger.error('Cannot register plugin: descriptor.id is null or undefined', plugin.descriptor); + logger.error('Cannot register plugin: manifest.id is null or undefined', plugin.manifest); return; } @@ -230,7 +241,7 @@ export class PluginManager implements IService { } const normalized = this.normalizePlugin(plugin); - const enabled = normalized.descriptor.isCore || normalized.descriptor.enabledByDefault; + const enabled = normalized.manifest.isCore || normalized.manifest.defaultEnabled; this.plugins.set(id, { plugin: normalized, @@ -239,7 +250,7 @@ export class PluginManager implements IService { loadedAt: Date.now() }); - logger.info(`Plugin registered: ${id} (${normalized.descriptor.name})`); + logger.info(`Plugin registered: ${id} (${normalized.manifest.displayName})`); } /** @@ -253,7 +264,7 @@ export class PluginManager implements IService { return false; } - if (plugin.plugin.descriptor.isCore) { + if (plugin.plugin.manifest.isCore) { logger.warn(`Core plugin ${pluginId} cannot be disabled/enabled`); return false; } @@ -263,13 +274,33 @@ export class PluginManager implements IService { return true; } - // 检查依赖 - const deps = plugin.plugin.descriptor.dependencies; - for (const dep of deps) { - if (dep.optional) continue; - const depPlugin = this.plugins.get(dep.id); - if (!depPlugin || !depPlugin.enabled) { - logger.error(`Cannot enable ${pluginId}: dependency ${dep.id} is not enabled`); + // 检查依赖(支持短 ID 和完整 ID) + // Check dependencies (supports both short ID and full ID) + const deps = plugin.plugin.manifest.dependencies; + for (const depId of deps) { + const resolvedDepId = this.resolveDependencyId(depId); + const depPlugin = this.plugins.get(resolvedDepId); + + // 如果依赖不在 plugins 中,检查是否是核心模块 + // If dependency is not in plugins, check if it's a core module + if (!depPlugin) { + // 核心模块(如 engine-core, core, math)不作为插件注册 + // Core modules (like engine-core, core, math) are not registered as plugins + // 它们总是可用的,所以跳过检查 + // They are always available, so skip the check + const shortId = depId.startsWith('@esengine/') ? depId.replace('@esengine/', '') : depId; + // 动态查询 moduleRegistry 判断是否是核心模块 + // Dynamically query moduleRegistry to check if it's a core module + const moduleEntry = moduleRegistry.getModule(shortId); + if (moduleEntry?.isCore) { + continue; + } + logger.error(`Cannot enable ${pluginId}: dependency ${depId} (resolved: ${resolvedDepId}) is not registered`); + return false; + } + + if (!depPlugin.enabled) { + logger.error(`Cannot enable ${pluginId}: dependency ${depId} (resolved: ${resolvedDepId}) is not enabled`); return false; } } @@ -312,7 +343,7 @@ export class PluginManager implements IService { return false; } - if (plugin.plugin.descriptor.isCore) { + if (plugin.plugin.manifest.isCore) { logger.warn(`Core plugin ${pluginId} cannot be disabled`); return false; } @@ -322,12 +353,13 @@ export class PluginManager implements IService { return true; } - // 检查是否有其他插件依赖此插件 + // 检查是否有其他插件依赖此插件(支持短 ID 和完整 ID) for (const [id, p] of this.plugins) { if (!p.enabled || id === pluginId) continue; - const deps = p.plugin.descriptor.dependencies; - const hasDep = deps.some(d => d.id === pluginId && !d.optional); - if (hasDep) { + const deps = p.plugin.manifest.dependencies; + // 将每个依赖解析为完整 ID 后检查 + const resolvedDeps = deps.map(d => this.resolveDependencyId(d)); + if (resolvedDeps.includes(pluginId)) { logger.error(`Cannot disable ${pluginId}: plugin ${id} depends on it`); return false; } @@ -684,9 +716,9 @@ export class PluginManager implements IService { * 按类别获取插件 * Get plugins by category */ - getPluginsByCategory(category: PluginCategory): RegisteredPlugin[] { + getPluginsByCategory(category: ModuleCategory): RegisteredPlugin[] { return this.getAllPlugins().filter( - p => p.plugin.descriptor.category === category + p => p.plugin.manifest.category === category ); } @@ -1015,7 +1047,7 @@ export class PluginManager implements IService { exportConfig(): PluginConfig { const enabledPlugins: string[] = []; for (const [id, plugin] of this.plugins) { - if (plugin.enabled && !plugin.plugin.descriptor.isCore) { + if (plugin.enabled && !plugin.plugin.manifest.isCore) { enabledPlugins.push(id); } } @@ -1040,7 +1072,7 @@ export class PluginManager implements IService { const toDisable: string[] = []; for (const [id, plugin] of this.plugins) { - if (plugin.plugin.descriptor.isCore) { + if (plugin.plugin.manifest.isCore) { continue; // 核心插件始终启用 } @@ -1068,35 +1100,13 @@ export class PluginManager implements IService { } /** - * 按加载阶段排序 - * Sort by loading phase + * 按依赖排序(拓扑排序) + * Sort by dependencies (topological sort) */ - private sortByLoadingPhase(moduleType: 'runtime' | 'editor'): string[] { + private sortByLoadingPhase(_moduleType: 'runtime' | 'editor'): string[] { const pluginIds = Array.from(this.plugins.keys()); - - // 先按依赖拓扑排序 - const sorted = this.topologicalSort(pluginIds); - - // 再按加载阶段排序(稳定排序) - sorted.sort((a, b) => { - const pluginA = this.plugins.get(a); - const pluginB = this.plugins.get(b); - - const moduleA = moduleType === 'runtime' - ? pluginA?.plugin.descriptor.modules.find(m => m.type === 'runtime') - : pluginA?.plugin.descriptor.modules.find(m => m.type === 'editor'); - - const moduleB = moduleType === 'runtime' - ? pluginB?.plugin.descriptor.modules.find(m => m.type === 'runtime') - : pluginB?.plugin.descriptor.modules.find(m => m.type === 'editor'); - - const phaseA = moduleA?.loadingPhase || 'default'; - const phaseB = moduleB?.loadingPhase || 'default'; - - return LOADING_PHASE_ORDER.indexOf(phaseA) - LOADING_PHASE_ORDER.indexOf(phaseB); - }); - - return sorted; + // 按依赖拓扑排序 + return this.topologicalSort(pluginIds); } /** @@ -1113,10 +1123,12 @@ export class PluginManager implements IService { const plugin = this.plugins.get(id); if (plugin) { - const deps = plugin.plugin.descriptor.dependencies || []; - for (const dep of deps) { - if (pluginIds.includes(dep.id)) { - visit(dep.id); + const deps = plugin.plugin.manifest.dependencies || []; + for (const depId of deps) { + // 解析短 ID 为完整 ID + const resolvedDepId = this.resolveDependencyId(depId); + if (pluginIds.includes(resolvedDepId)) { + visit(resolvedDepId); } } } diff --git a/packages/editor-core/src/Rendering/IViewportOverlay.ts b/packages/editor-core/src/Rendering/IViewportOverlay.ts new file mode 100644 index 00000000..bcf0ec7d --- /dev/null +++ b/packages/editor-core/src/Rendering/IViewportOverlay.ts @@ -0,0 +1,198 @@ +/** + * Viewport Overlay Interface + * 视口覆盖层接口 + * + * Defines the interface for rendering overlays on viewports (grid, selection, gizmos, etc.) + * 定义在视口上渲染覆盖层的接口(网格、选区、辅助线等) + */ + +import type { ViewportCameraConfig } from '../Services/IViewportService'; + +/** + * Context passed to overlay renderers + * 传递给覆盖层渲染器的上下文 + */ +export interface OverlayRenderContext { + /** Current camera state | 当前相机状态 */ + camera: ViewportCameraConfig; + /** Viewport dimensions | 视口尺寸 */ + viewport: { width: number; height: number }; + /** Device pixel ratio | 设备像素比 */ + dpr: number; + /** Selected entity IDs (if applicable) | 选中的实体 ID(如果适用) */ + selectedEntityIds?: number[]; + /** Delta time since last frame | 距上一帧的时间差 */ + deltaTime: number; + /** Add a line gizmo | 添加线条辅助线 */ + addLine(x1: number, y1: number, x2: number, y2: number, color: number, thickness?: number): void; + /** Add a rectangle gizmo (outline) | 添加矩形辅助线(轮廓) */ + addRect(x: number, y: number, width: number, height: number, color: number, thickness?: number): void; + /** Add a filled rectangle gizmo | 添加填充矩形辅助线 */ + addFilledRect(x: number, y: number, width: number, height: number, color: number): void; + /** Add a circle gizmo (outline) | 添加圆形辅助线(轮廓) */ + addCircle(x: number, y: number, radius: number, color: number, thickness?: number): void; + /** Add a filled circle gizmo | 添加填充圆形辅助线 */ + addFilledCircle(x: number, y: number, radius: number, color: number): void; + /** Add text | 添加文本 */ + addText?(text: string, x: number, y: number, color: number, fontSize?: number): void; +} + +/** + * Interface for viewport overlays (grid, selection, etc.) + * 视口覆盖层接口(网格、选区等) + */ +export interface IViewportOverlay { + /** Unique overlay identifier | 唯一覆盖层标识符 */ + readonly id: string; + /** Priority (higher = rendered later/on top) | 优先级(越高越晚渲染/在上层) */ + priority: number; + /** Whether overlay is visible | 覆盖层是否可见 */ + visible: boolean; + + /** + * Render the overlay + * 渲染覆盖层 + * @param context - Render context with camera, viewport info, and gizmo APIs + */ + render(context: OverlayRenderContext): void; + + /** + * Update the overlay (optional, called each frame before render) + * 更新覆盖层(可选,每帧在渲染前调用) + * @param deltaTime - Time since last update + */ + update?(deltaTime: number): void; + + /** + * Dispose the overlay resources + * 释放覆盖层资源 + */ + dispose?(): void; +} + +/** + * Base class for viewport overlays + * 视口覆盖层基类 + */ +export abstract class ViewportOverlayBase implements IViewportOverlay { + abstract readonly id: string; + priority = 0; + visible = true; + + abstract render(context: OverlayRenderContext): void; + + update?(deltaTime: number): void; + + dispose?(): void; +} + +/** + * Grid overlay for viewports + * 视口网格覆盖层 + */ +export class GridOverlay extends ViewportOverlayBase { + override readonly id = 'grid'; + override priority = 0; + + /** Grid cell size in world units | 网格单元格大小(世界单位) */ + cellSize = 32; + /** Grid line color (ARGB packed) | 网格线颜色(ARGB 打包) */ + lineColor = 0x40FFFFFF; + /** Major grid line interval | 主网格线间隔 */ + majorLineInterval = 10; + /** Major grid line color (ARGB packed) | 主网格线颜色(ARGB 打包) */ + majorLineColor = 0x60FFFFFF; + /** Show axis lines | 显示轴线 */ + showAxisLines = true; + /** X axis color | X 轴颜色 */ + xAxisColor = 0xFFFF5555; + /** Y axis color | Y 轴颜色 */ + yAxisColor = 0xFF55FF55; + + render(context: OverlayRenderContext): void { + const { camera, viewport } = context; + const halfWidth = (viewport.width / 2) / camera.zoom; + const halfHeight = (viewport.height / 2) / camera.zoom; + + // Calculate visible grid range + const left = camera.x - halfWidth; + const right = camera.x + halfWidth; + const bottom = camera.y - halfHeight; + const top = camera.y + halfHeight; + + // Round to grid lines + const startX = Math.floor(left / this.cellSize) * this.cellSize; + const endX = Math.ceil(right / this.cellSize) * this.cellSize; + const startY = Math.floor(bottom / this.cellSize) * this.cellSize; + const endY = Math.ceil(top / this.cellSize) * this.cellSize; + + // Draw vertical lines + for (let x = startX; x <= endX; x += this.cellSize) { + const isMajor = x % (this.cellSize * this.majorLineInterval) === 0; + const color = isMajor ? this.majorLineColor : this.lineColor; + context.addLine(x, startY, x, endY, color, isMajor ? 1.5 : 1); + } + + // Draw horizontal lines + for (let y = startY; y <= endY; y += this.cellSize) { + const isMajor = y % (this.cellSize * this.majorLineInterval) === 0; + const color = isMajor ? this.majorLineColor : this.lineColor; + context.addLine(startX, y, endX, y, color, isMajor ? 1.5 : 1); + } + + // Draw axis lines + if (this.showAxisLines) { + // X axis (red) + if (bottom <= 0 && top >= 0) { + context.addLine(startX, 0, endX, 0, this.xAxisColor, 2); + } + // Y axis (green) + if (left <= 0 && right >= 0) { + context.addLine(0, startY, 0, endY, this.yAxisColor, 2); + } + } + } +} + +/** + * Selection highlight overlay + * 选区高亮覆盖层 + */ +export class SelectionOverlay extends ViewportOverlayBase { + override readonly id = 'selection'; + override priority = 100; + + /** Selection highlight color (ARGB packed) | 选区高亮颜色(ARGB 打包) */ + highlightColor = 0x404488FF; + /** Selection border color (ARGB packed) | 选区边框颜色(ARGB 打包) */ + borderColor = 0xFF4488FF; + /** Border thickness | 边框厚度 */ + borderThickness = 2; + + private _selections: Array<{ x: number; y: number; width: number; height: number }> = []; + + /** + * Set selection rectangles + * 设置选区矩形 + */ + setSelections(selections: Array<{ x: number; y: number; width: number; height: number }>): void { + this._selections = selections; + } + + /** + * Clear all selections + * 清除所有选区 + */ + clearSelections(): void { + this._selections = []; + } + + render(context: OverlayRenderContext): void { + for (const sel of this._selections) { + // Draw filled rectangle + context.addFilledRect(sel.x, sel.y, sel.width, sel.height, this.highlightColor); + // Draw border + context.addRect(sel.x, sel.y, sel.width, sel.height, this.borderColor, this.borderThickness); + } + } +} diff --git a/packages/editor-core/src/Rendering/index.ts b/packages/editor-core/src/Rendering/index.ts new file mode 100644 index 00000000..45fc90e7 --- /dev/null +++ b/packages/editor-core/src/Rendering/index.ts @@ -0,0 +1,6 @@ +/** + * Rendering module exports + * 渲染模块导出 + */ + +export * from './IViewportOverlay'; diff --git a/packages/editor-core/src/Services/AssetRegistryService.ts b/packages/editor-core/src/Services/AssetRegistryService.ts index a9509ad8..7d30f756 100644 --- a/packages/editor-core/src/Services/AssetRegistryService.ts +++ b/packages/editor-core/src/Services/AssetRegistryService.ts @@ -4,22 +4,25 @@ * * 负责扫描项目资产目录,为每个资产生成唯一GUID, * 并维护 GUID ↔ 路径 的映射关系。 + * 使用 .meta 文件持久化存储每个资产的 GUID。 * * Responsible for scanning project asset directories, * generating unique GUIDs for each asset, and maintaining * GUID ↔ path mappings. + * Uses .meta files to persistently store each asset's GUID. */ -import { Core } from '@esengine/ecs-framework'; +import { Core, createLogger } from '@esengine/ecs-framework'; import { MessageHub } from './MessageHub'; +import { + AssetMetaManager, + IAssetMeta, + IMetaFileSystem, + inferAssetType +} from '@esengine/asset-system-editor'; -// Simple logger for AssetRegistry -const logger = { - info: (msg: string, ...args: unknown[]) => console.log(`[AssetRegistry] ${msg}`, ...args), - warn: (msg: string, ...args: unknown[]) => console.warn(`[AssetRegistry] ${msg}`, ...args), - error: (msg: string, ...args: unknown[]) => console.error(`[AssetRegistry] ${msg}`, ...args), - debug: (msg: string, ...args: unknown[]) => console.debug(`[AssetRegistry] ${msg}`, ...args), -}; +// Logger for AssetRegistry using core's logger +const logger = createLogger('AssetRegistry'); /** * Asset GUID type (simplified, no dependency on asset-system) @@ -213,6 +216,9 @@ export class AssetRegistryService { private _messageHub: MessageHub | null = null; private _initialized = false; + /** Asset meta manager for .meta file management */ + private _metaManager: AssetMetaManager; + /** Manifest file name */ static readonly MANIFEST_FILE = 'asset-manifest.json'; /** Current manifest version */ @@ -220,6 +226,15 @@ export class AssetRegistryService { constructor() { this._database = new SimpleAssetDatabase(); + this._metaManager = new AssetMetaManager(); + } + + /** + * Get the AssetMetaManager instance + * 获取 AssetMetaManager 实例 + */ + get metaManager(): AssetMetaManager { + return this._metaManager; } /** @@ -270,11 +285,32 @@ export class AssetRegistryService { this._projectPath = projectPath; this._database.clear(); + this._metaManager.clear(); - // Try to load existing manifest + // Setup MetaManager with file system adapter + const metaFs: IMetaFileSystem = { + exists: (path: string) => this._fileSystem!.exists(path), + readText: (path: string) => this._fileSystem!.readFile(path), + writeText: (path: string, content: string) => this._fileSystem!.writeFile(path, content), + delete: async (path: string) => { + // Try to delete, ignore if not exists + try { + // Note: IFileSystem may not have delete, handle gracefully + const fs = this._fileSystem as IFileSystem & { delete?: (p: string) => Promise }; + if (fs.delete) { + await fs.delete(path); + } + } catch { + // Ignore delete errors + } + } + }; + this._metaManager.setFileSystem(metaFs); + + // Try to load existing manifest (for backward compatibility) await this._loadManifest(); - // Scan assets directory + // Scan assets directory (now uses .meta files) await this._scanAssetsDirectory(); // Save updated manifest @@ -422,15 +458,18 @@ export class AssetRegistryService { private async _registerAssetFile(absolutePath: string, relativePath: string): Promise { if (!this._fileSystem || !this._manifest) return; + // Skip .meta files + if (relativePath.endsWith('.meta')) return; + // Get file extension const lastDot = relativePath.lastIndexOf('.'); if (lastDot === -1) return; // Skip files without extension const extension = relativePath.substring(lastDot).toLowerCase(); - const assetType = EXTENSION_TYPE_MAP[extension]; + const assetType = EXTENSION_TYPE_MAP[extension] || inferAssetType(relativePath); // Skip unknown file types - if (!assetType) return; + if (!assetType || assetType === 'binary') return; // Get file info let stat: { size: number; mtime: number }; @@ -440,15 +479,19 @@ export class AssetRegistryService { return; } - // Check if already in manifest - let guid: AssetGUID; - const existingEntry = this._manifest.assets[relativePath]; + // Use MetaManager to get or create meta (with .meta file) + let meta: IAssetMeta; + try { + meta = await this._metaManager.getOrCreateMeta(absolutePath); + } catch (e) { + logger.warn(`Failed to get meta for ${relativePath}:`, e); + return; + } - if (existingEntry) { - guid = existingEntry.guid; - } else { - // Generate new GUID - guid = this._generateGUID(); + const guid = meta.guid; + + // Update manifest for backward compatibility + if (!this._manifest.assets[relativePath]) { this._manifest.assets[relativePath] = { guid, relativePath, diff --git a/packages/editor-core/src/Services/Build/BuildService.ts b/packages/editor-core/src/Services/Build/BuildService.ts new file mode 100644 index 00000000..2a36ff06 --- /dev/null +++ b/packages/editor-core/src/Services/Build/BuildService.ts @@ -0,0 +1,309 @@ +/** + * Build Service. + * 构建服务。 + * + * Manages build pipelines and executes build tasks. + * 管理构建管线和执行构建任务。 + */ + +import type { IService } from '@esengine/ecs-framework'; +import type { + IBuildPipeline, + IBuildPipelineRegistry, + BuildPlatform, + BuildConfig, + BuildResult, + BuildProgress +} from './IBuildPipeline'; +import { BuildStatus } from './IBuildPipeline'; + +/** + * Build task. + * 构建任务。 + */ +export interface BuildTask { + /** Task ID | 任务 ID */ + id: string; + /** Target platform | 目标平台 */ + platform: BuildPlatform; + /** Build configuration | 构建配置 */ + config: BuildConfig; + /** Current progress | 当前进度 */ + progress: BuildProgress; + /** Start time | 开始时间 */ + startTime: Date; + /** End time | 结束时间 */ + endTime?: Date; + /** Abort controller | 中止控制器 */ + abortController: AbortController; +} + +/** + * Build Service. + * 构建服务。 + * + * Provides build pipeline registration and build task management. + * 提供构建管线注册和构建任务管理。 + * + * @example + * ```typescript + * const buildService = services.resolve(BuildService); + * + * // Register build pipeline | 注册构建管线 + * buildService.registerPipeline(new WebBuildPipeline()); + * buildService.registerPipeline(new WeChatBuildPipeline()); + * + * // Execute build | 执行构建 + * const result = await buildService.build({ + * platform: BuildPlatform.Web, + * outputPath: './dist', + * isRelease: true, + * sourceMap: false + * }, (progress) => { + * console.log(`${progress.message} (${progress.progress}%)`); + * }); + * ``` + */ +export class BuildService implements IService, IBuildPipelineRegistry { + private _pipelines = new Map(); + private _currentTask: BuildTask | null = null; + private _taskHistory: BuildTask[] = []; + private _maxHistorySize = 10; + + /** + * Dispose service resources. + * 释放服务资源。 + */ + dispose(): void { + this.cancelBuild(); + this._pipelines.clear(); + this._taskHistory = []; + } + + /** + * Register build pipeline. + * 注册构建管线。 + * + * @param pipeline - Build pipeline instance | 构建管线实例 + */ + register(pipeline: IBuildPipeline): void { + if (this._pipelines.has(pipeline.platform)) { + console.warn(`[BuildService] Overwriting existing pipeline: ${pipeline.platform} | 覆盖已存在的构建管线: ${pipeline.platform}`); + } + this._pipelines.set(pipeline.platform, pipeline); + console.log(`[BuildService] Registered pipeline: ${pipeline.displayName} | 注册构建管线: ${pipeline.displayName}`); + } + + /** + * Get build pipeline. + * 获取构建管线。 + * + * @param platform - Target platform | 目标平台 + * @returns Build pipeline, or undefined if not registered | 构建管线,如果未注册则返回 undefined + */ + get(platform: BuildPlatform): IBuildPipeline | undefined { + return this._pipelines.get(platform); + } + + /** + * Get all registered build pipelines. + * 获取所有已注册的构建管线。 + * + * @returns Build pipeline list | 构建管线列表 + */ + getAll(): IBuildPipeline[] { + return Array.from(this._pipelines.values()); + } + + /** + * Check if platform is registered. + * 检查平台是否已注册。 + * + * @param platform - Target platform | 目标平台 + * @returns Whether registered | 是否已注册 + */ + has(platform: BuildPlatform): boolean { + return this._pipelines.has(platform); + } + + /** + * Get available build platforms. + * 获取可用的构建平台。 + * + * Checks availability of each registered platform. + * 检查每个已注册平台的可用性。 + * + * @returns Available platforms and their status | 可用平台及其状态 + */ + async getAvailablePlatforms(): Promise> { + const results = []; + + for (const pipeline of this._pipelines.values()) { + const availability = await pipeline.checkAvailability(); + results.push({ + platform: pipeline.platform, + displayName: pipeline.displayName, + description: pipeline.description, + available: availability.available, + reason: availability.reason + }); + } + + return results; + } + + /** + * Execute build. + * 执行构建。 + * + * @param config - Build configuration | 构建配置 + * @param onProgress - Progress callback | 进度回调 + * @returns Build result | 构建结果 + */ + async build( + config: BuildConfig, + onProgress?: (progress: BuildProgress) => void + ): Promise { + // Check if there's an ongoing build | 检查是否有正在进行的构建 + if (this._currentTask) { + throw new Error('A build task is already in progress | 已有构建任务正在进行中'); + } + + // Get build pipeline | 获取构建管线 + const pipeline = this._pipelines.get(config.platform); + if (!pipeline) { + throw new Error(`Pipeline not found for platform ${config.platform} | 未找到平台 ${config.platform} 的构建管线`); + } + + // Validate configuration | 验证配置 + const errors = pipeline.validateConfig(config); + if (errors.length > 0) { + throw new Error(`Invalid build configuration | 构建配置无效:\n${errors.join('\n')}`); + } + + // Create build task | 创建构建任务 + const abortController = new AbortController(); + const task: BuildTask = { + id: this._generateTaskId(), + platform: config.platform, + config, + progress: { + status: 'preparing' as BuildStatus, + message: 'Preparing build... | 准备构建...', + progress: 0, + currentStep: 0, + totalSteps: 0, + warnings: [] + }, + startTime: new Date(), + abortController + }; + + this._currentTask = task; + + try { + // Execute build | 执行构建 + const result = await pipeline.build( + config, + (progress) => { + task.progress = progress; + onProgress?.(progress); + }, + abortController.signal + ); + + // Update task status | 更新任务状态 + task.endTime = new Date(); + task.progress.status = result.success ? BuildStatus.Completed : BuildStatus.Failed; + + // Add to history | 添加到历史 + this._addToHistory(task); + + return result; + } catch (error) { + // Handle error | 处理错误 + task.endTime = new Date(); + task.progress.status = BuildStatus.Failed; + task.progress.error = error instanceof Error ? error.message : String(error); + + this._addToHistory(task); + + return { + success: false, + platform: config.platform, + outputPath: config.outputPath, + duration: task.endTime.getTime() - task.startTime.getTime(), + outputFiles: [], + warnings: task.progress.warnings, + error: task.progress.error + }; + } finally { + this._currentTask = null; + } + } + + /** + * Cancel current build. + * 取消当前构建。 + */ + cancelBuild(): void { + if (this._currentTask) { + this._currentTask.abortController.abort(); + this._currentTask.progress.status = BuildStatus.Cancelled; + console.log('[BuildService] Build cancelled | 构建已取消'); + } + } + + /** + * Get current build task. + * 获取当前构建任务。 + * + * @returns Current task, or null if none | 当前任务,如果没有则返回 null + */ + getCurrentTask(): BuildTask | null { + return this._currentTask; + } + + /** + * Get build history. + * 获取构建历史。 + * + * @returns History task list (newest first) | 历史任务列表(最新的在前) + */ + getHistory(): BuildTask[] { + return [...this._taskHistory]; + } + + /** + * Clear build history. + * 清除构建历史。 + */ + clearHistory(): void { + this._taskHistory = []; + } + + /** + * Generate task ID. + * 生成任务 ID。 + */ + private _generateTaskId(): string { + return `build-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; + } + + /** + * Add task to history. + * 添加任务到历史。 + */ + private _addToHistory(task: BuildTask): void { + this._taskHistory.unshift(task); + if (this._taskHistory.length > this._maxHistorySize) { + this._taskHistory.pop(); + } + } +} diff --git a/packages/editor-core/src/Services/Build/IBuildPipeline.ts b/packages/editor-core/src/Services/Build/IBuildPipeline.ts new file mode 100644 index 00000000..c5d340ad --- /dev/null +++ b/packages/editor-core/src/Services/Build/IBuildPipeline.ts @@ -0,0 +1,319 @@ +/** + * Build Pipeline Interface. + * 构建管线接口。 + * + * Defines the common process and configuration for platform builds. + * 定义平台构建的通用流程和配置。 + */ + +/** + * Build target platform. + * 构建目标平台。 + */ +export enum BuildPlatform { + /** Web/H5 browser | Web/H5 浏览器 */ + Web = 'web', + /** WeChat MiniGame | 微信小游戏 */ + WeChatMiniGame = 'wechat-minigame', + /** ByteDance MiniGame | 字节跳动小游戏 */ + ByteDanceMiniGame = 'bytedance-minigame', + /** Alipay MiniGame | 支付宝小游戏 */ + AlipayMiniGame = 'alipay-minigame', + /** Desktop application (Tauri) | 桌面应用 (Tauri) */ + Desktop = 'desktop', + /** Android | Android */ + Android = 'android', + /** iOS | iOS */ + iOS = 'ios' +} + +/** + * Build status. + * 构建状态。 + */ +export enum BuildStatus { + /** Idle | 空闲 */ + Idle = 'idle', + /** Preparing | 准备中 */ + Preparing = 'preparing', + /** Compiling | 编译中 */ + Compiling = 'compiling', + /** Packaging assets | 打包资源 */ + Packaging = 'packaging', + /** Copying files | 复制文件 */ + Copying = 'copying', + /** Post-processing | 后处理 */ + PostProcessing = 'post-processing', + /** Completed | 完成 */ + Completed = 'completed', + /** Failed | 失败 */ + Failed = 'failed', + /** Cancelled | 已取消 */ + Cancelled = 'cancelled' +} + +/** + * Build progress information. + * 构建进度信息。 + */ +export interface BuildProgress { + /** Current status | 当前状态 */ + status: BuildStatus; + /** Current step description | 当前步骤描述 */ + message: string; + /** Overall progress (0-100) | 总体进度 (0-100) */ + progress: number; + /** Current step index | 当前步骤索引 */ + currentStep: number; + /** Total step count | 总步骤数 */ + totalSteps: number; + /** Warning list | 警告列表 */ + warnings: string[]; + /** Error message (if failed) | 错误信息(如果失败) */ + error?: string; +} + +/** + * Build configuration base class. + * 构建配置基类。 + */ +export interface BuildConfig { + /** Target platform | 目标平台 */ + platform: BuildPlatform; + /** Output directory | 输出目录 */ + outputPath: string; + /** Whether release build (compression, optimization) | 是否为发布构建(压缩、优化) */ + isRelease: boolean; + /** Whether to generate source map | 是否生成 source map */ + sourceMap: boolean; + /** Scene list to include (empty means all) | 要包含的场景列表(空表示全部) */ + scenes?: string[]; + /** Plugin list to include (empty means all enabled) | 要包含的插件列表(空表示全部启用的) */ + plugins?: string[]; + /** + * Enabled module IDs (whitelist approach). + * 启用的模块 ID 列表(白名单方式)。 + * If set, only these modules will be included. + * 如果设置,只会包含这些模块。 + */ + enabledModules?: string[]; + /** + * Disabled module IDs (blacklist approach). + * 禁用的模块 ID 列表(黑名单方式)。 + * If set, all modules EXCEPT these will be included. + * 如果设置,会包含除了这些之外的所有模块。 + * Takes precedence over enabledModules. + * 优先于 enabledModules。 + */ + disabledModules?: string[]; +} + +/** + * Web platform build configuration. + * Web 平台构建配置。 + */ +export interface WebBuildConfig extends BuildConfig { + platform: BuildPlatform.Web; + /** Output format | 输出格式 */ + format: 'iife' | 'esm'; + /** + * Whether to bundle all modules into a single JS file. + * 是否将所有模块打包成单个 JS 文件。 + * - true: Bundle into one runtime.browser.js (smaller total size, single request) + * - false: Keep modules separate (better caching, parallel loading) + * Default: true + */ + bundleModules: boolean; + /** Whether to generate HTML file | 是否生成 HTML 文件 */ + generateHtml: boolean; + /** HTML template path | HTML 模板路径 */ + htmlTemplate?: string; +} + +/** + * WeChat MiniGame build configuration. + * 微信小游戏构建配置。 + */ +export interface WeChatBuildConfig extends BuildConfig { + platform: BuildPlatform.WeChatMiniGame; + /** AppID | AppID */ + appId: string; + /** Whether to use subpackages | 是否分包 */ + useSubpackages: boolean; + /** Main package size limit (KB) | 主包大小限制 (KB) */ + mainPackageLimit: number; + /** Whether to enable plugins | 是否启用插件 */ + usePlugins: boolean; +} + +/** + * Build result. + * 构建结果。 + */ +export interface BuildResult { + /** Whether successful | 是否成功 */ + success: boolean; + /** Target platform | 目标平台 */ + platform: BuildPlatform; + /** Output directory | 输出目录 */ + outputPath: string; + /** Build duration (milliseconds) | 构建耗时(毫秒) */ + duration: number; + /** Output file list | 输出文件列表 */ + outputFiles: string[]; + /** Warning list | 警告列表 */ + warnings: string[]; + /** Error message (if failed) | 错误信息(如果失败) */ + error?: string; + /** Build statistics | 构建统计 */ + stats?: { + /** Total file size (bytes) | 总文件大小 (bytes) */ + totalSize: number; + /** JS file size | JS 文件大小 */ + jsSize: number; + /** WASM file size | WASM 文件大小 */ + wasmSize: number; + /** Asset file size | 资源文件大小 */ + assetsSize: number; + }; +} + +/** + * Build step. + * 构建步骤。 + */ +export interface BuildStep { + /** Step ID | 步骤 ID */ + id: string; + /** Step name | 步骤名称 */ + name: string; + /** Execute function | 执行函数 */ + execute: (context: BuildContext) => Promise; + /** Whether skippable | 是否可跳过 */ + optional?: boolean; +} + +/** + * Build context. + * 构建上下文。 + * + * Shared state during the build process. + * 在构建过程中共享的状态。 + */ +export interface BuildContext { + /** Build configuration | 构建配置 */ + config: BuildConfig; + /** Project root directory | 项目根目录 */ + projectRoot: string; + /** Temporary directory | 临时目录 */ + tempDir: string; + /** Output directory | 输出目录 */ + outputDir: string; + /** Progress report callback | 进度报告回调 */ + reportProgress: (message: string, progress?: number) => void; + /** Add warning | 添加警告 */ + addWarning: (warning: string) => void; + /** Abort signal | 中止信号 */ + abortSignal: AbortSignal; + /** Shared data (passed between steps) | 共享数据(步骤间传递) */ + data: Map; +} + +/** + * Build pipeline interface. + * 构建管线接口。 + * + * Each platform implements its own build pipeline. + * 每个平台实现自己的构建管线。 + */ +export interface IBuildPipeline { + /** Platform identifier | 平台标识 */ + readonly platform: BuildPlatform; + + /** Platform display name | 平台显示名称 */ + readonly displayName: string; + + /** Platform icon | 平台图标 */ + readonly icon?: string; + + /** Platform description | 平台描述 */ + readonly description?: string; + + /** + * Get default configuration. + * 获取默认配置。 + */ + getDefaultConfig(): BuildConfig; + + /** + * Validate configuration. + * 验证配置是否有效。 + * + * @param config - Build configuration | 构建配置 + * @returns Validation error list (empty means valid) | 验证错误列表(空表示有效) + */ + validateConfig(config: BuildConfig): string[]; + + /** + * Get build steps. + * 获取构建步骤。 + * + * @param config - Build configuration | 构建配置 + * @returns Build step list | 构建步骤列表 + */ + getSteps(config: BuildConfig): BuildStep[]; + + /** + * Execute build. + * 执行构建。 + * + * @param config - Build configuration | 构建配置 + * @param onProgress - Progress callback | 进度回调 + * @param abortSignal - Abort signal | 中止信号 + * @returns Build result | 构建结果 + */ + build( + config: BuildConfig, + onProgress?: (progress: BuildProgress) => void, + abortSignal?: AbortSignal + ): Promise; + + /** + * Check platform availability. + * 检查平台是否可用。 + * + * For example, check if necessary tools are installed. + * 例如检查必要的工具是否安装。 + */ + checkAvailability(): Promise<{ available: boolean; reason?: string }>; +} + +/** + * Build pipeline registry interface. + * 构建管线注册表接口。 + */ +export interface IBuildPipelineRegistry { + /** + * Register build pipeline. + * 注册构建管线。 + */ + register(pipeline: IBuildPipeline): void; + + /** + * Get build pipeline. + * 获取构建管线。 + */ + get(platform: BuildPlatform): IBuildPipeline | undefined; + + /** + * Get all registered pipelines. + * 获取所有已注册的管线。 + */ + getAll(): IBuildPipeline[]; + + /** + * Check if platform is registered. + * 检查平台是否已注册。 + */ + has(platform: BuildPlatform): boolean; +} diff --git a/packages/editor-core/src/Services/Build/index.ts b/packages/editor-core/src/Services/Build/index.ts new file mode 100644 index 00000000..330aab99 --- /dev/null +++ b/packages/editor-core/src/Services/Build/index.ts @@ -0,0 +1,26 @@ +/** + * Build System. + * 构建系统。 + * + * Provides cross-platform project build capabilities. + * 提供跨平台的项目构建能力。 + */ + +export { + BuildPlatform, + BuildStatus, + type BuildProgress, + type BuildConfig, + type WebBuildConfig, + type WeChatBuildConfig, + type BuildResult, + type BuildStep, + type BuildContext, + type IBuildPipeline, + type IBuildPipelineRegistry +} from './IBuildPipeline'; + +export { BuildService, type BuildTask } from './BuildService'; + +// Build pipelines | 构建管线 +export { WebBuildPipeline, WeChatBuildPipeline, type IBuildFileSystem } from './pipelines'; diff --git a/packages/editor-core/src/Services/Build/pipelines/WeChatBuildPipeline.ts b/packages/editor-core/src/Services/Build/pipelines/WeChatBuildPipeline.ts new file mode 100644 index 00000000..a7c147f2 --- /dev/null +++ b/packages/editor-core/src/Services/Build/pipelines/WeChatBuildPipeline.ts @@ -0,0 +1,730 @@ +/** + * WeChat MiniGame Build Pipeline. + * 微信小游戏构建管线。 + * + * Packages the project as a format that can run on WeChat MiniGame platform. + * 将项目打包为可在微信小游戏平台运行的格式。 + */ + +import type { + IBuildPipeline, + BuildConfig, + BuildResult, + BuildProgress, + BuildStep, + BuildContext, + WeChatBuildConfig +} from '../IBuildPipeline'; +import { BuildPlatform, BuildStatus } from '../IBuildPipeline'; +import type { IBuildFileSystem } from './WebBuildPipeline'; + +/** + * WASM file configuration to be copied. + * 需要复制的 WASM 文件配置。 + */ +interface WasmFileConfig { + /** Source file path (relative to node_modules) | 源文件路径(相对于 node_modules) */ + source: string; + /** Target file path (relative to output directory) | 目标文件路径(相对于输出目录) */ + target: string; + /** Description | 描述 */ + description: string; +} + +/** + * WeChat MiniGame Build Pipeline. + * 微信小游戏构建管线。 + * + * Build steps: + * 构建步骤: + * 1. Prepare output directory | 准备输出目录 + * 2. Compile TypeScript | 编译 TypeScript + * 3. Bundle runtime (using WeChat adapter) | 打包运行时(使用微信适配器) + * 4. Copy WASM files | 复制 WASM 文件 + * 5. Copy asset files | 复制资源文件 + * 6. Generate game.json | 生成 game.json + * 7. Generate game.js | 生成 game.js + * 8. Post-process | 后处理 + */ +export class WeChatBuildPipeline implements IBuildPipeline { + readonly platform = BuildPlatform.WeChatMiniGame; + readonly displayName = 'WeChat MiniGame | 微信小游戏'; + readonly description = 'Build as a format that can run on WeChat MiniGame platform | 构建为可在微信小游戏平台运行的格式'; + readonly icon = 'message-circle'; + + private _fileSystem: IBuildFileSystem | null = null; + + /** + * WASM file list to be copied. + * 需要复制的 WASM 文件列表。 + */ + private readonly _wasmFiles: WasmFileConfig[] = [ + { + source: '@dimforge/rapier2d/rapier_wasm2d_bg.wasm', + target: 'wasm/rapier2d_bg.wasm', + description: 'Rapier2D Physics Engine | Rapier2D 物理引擎' + } + // More WASM files can be added here | 可以在这里添加更多 WASM 文件 + ]; + + /** + * Set build file system service. + * 设置构建文件系统服务。 + * + * @param fileSystem - Build file system service | 构建文件系统服务 + */ + setFileSystem(fileSystem: IBuildFileSystem): void { + this._fileSystem = fileSystem; + } + + /** + * Get default configuration. + * 获取默认配置。 + */ + getDefaultConfig(): WeChatBuildConfig { + return { + platform: BuildPlatform.WeChatMiniGame, + outputPath: './dist/wechat', + isRelease: true, + sourceMap: false, + appId: '', + useSubpackages: false, + mainPackageLimit: 4096, // 4MB + usePlugins: false + }; + } + + /** + * Validate configuration. + * 验证配置。 + * + * @param config - Build configuration | 构建配置 + * @returns Validation error list | 验证错误列表 + */ + validateConfig(config: BuildConfig): string[] { + const errors: string[] = []; + const wxConfig = config as WeChatBuildConfig; + + if (!wxConfig.outputPath) { + errors.push('Output path cannot be empty | 输出路径不能为空'); + } + + if (!wxConfig.appId) { + errors.push('AppID cannot be empty | AppID 不能为空'); + } else if (!/^wx[a-f0-9]{16}$/.test(wxConfig.appId)) { + errors.push('AppID format is incorrect (should be 18 characters starting with wx) | AppID 格式不正确(应为 wx 开头的18位字符)'); + } + + if (wxConfig.mainPackageLimit < 1024) { + errors.push('Main package size limit cannot be less than 1MB | 主包大小限制不能小于 1MB'); + } + + return errors; + } + + /** + * Get build steps. + * 获取构建步骤。 + * + * @param config - Build configuration | 构建配置 + * @returns Build step list | 构建步骤列表 + */ + getSteps(config: BuildConfig): BuildStep[] { + const wxConfig = config as WeChatBuildConfig; + + const steps: BuildStep[] = [ + { + id: 'prepare', + name: 'Prepare output directory | 准备输出目录', + execute: this._prepareOutputDir.bind(this) + }, + { + id: 'compile', + name: 'Compile TypeScript | 编译 TypeScript', + execute: this._compileTypeScript.bind(this) + }, + { + id: 'bundle-runtime', + name: 'Bundle runtime | 打包运行时', + execute: this._bundleRuntime.bind(this) + }, + { + id: 'copy-wasm', + name: 'Copy WASM files | 复制 WASM 文件', + execute: this._copyWasmFiles.bind(this) + }, + { + id: 'copy-assets', + name: 'Copy asset files | 复制资源文件', + execute: this._copyAssets.bind(this) + }, + { + id: 'generate-game-json', + name: 'Generate game.json | 生成 game.json', + execute: this._generateGameJson.bind(this) + }, + { + id: 'generate-game-js', + name: 'Generate game.js | 生成 game.js', + execute: this._generateGameJs.bind(this) + }, + { + id: 'generate-project-config', + name: 'Generate project.config.json | 生成 project.config.json', + execute: this._generateProjectConfig.bind(this) + } + ]; + + if (wxConfig.useSubpackages) { + steps.push({ + id: 'split-subpackages', + name: 'Process subpackages | 分包处理', + execute: this._splitSubpackages.bind(this) + }); + } + + if (wxConfig.isRelease) { + steps.push({ + id: 'optimize', + name: 'Optimize and compress | 优化压缩', + execute: this._optimize.bind(this), + optional: true + }); + } + + return steps; + } + + /** + * Execute build. + * 执行构建。 + * + * @param config - Build configuration | 构建配置 + * @param onProgress - Progress callback | 进度回调 + * @param abortSignal - Abort signal | 中止信号 + * @returns Build result | 构建结果 + */ + async build( + config: BuildConfig, + onProgress?: (progress: BuildProgress) => void, + abortSignal?: AbortSignal + ): Promise { + const startTime = Date.now(); + const warnings: string[] = []; + const outputFiles: string[] = []; + + const steps = this.getSteps(config); + const totalSteps = steps.length; + + // Infer project root from output path | 从输出路径推断项目根目录 + // outputPath is typically: /path/to/project/build/wechat-minigame + // So we go up 2 levels to get project root | 所以我们向上2级获取项目根目录 + const outputPathParts = config.outputPath.replace(/\\/g, '/').split('/'); + const buildIndex = outputPathParts.lastIndexOf('build'); + const projectRoot = buildIndex > 0 + ? outputPathParts.slice(0, buildIndex).join('/') + : '.'; + + // Create build context | 创建构建上下文 + const context: BuildContext = { + config, + projectRoot, + tempDir: `${projectRoot}/temp/build-wechat`, + outputDir: config.outputPath, + reportProgress: (message, progress) => { + // Handled below | 在下面处理 + }, + addWarning: (warning) => { + warnings.push(warning); + }, + abortSignal: abortSignal || new AbortController().signal, + data: new Map() + }; + + // Store file system and WASM config for subsequent steps | 存储文件系统和 WASM 配置供后续步骤使用 + context.data.set('fileSystem', this._fileSystem); + context.data.set('wasmFiles', this._wasmFiles); + + try { + for (let i = 0; i < steps.length; i++) { + const step = steps[i]; + + if (abortSignal?.aborted) { + return { + success: false, + platform: config.platform, + outputPath: config.outputPath, + duration: Date.now() - startTime, + outputFiles, + warnings, + error: 'Build cancelled | 构建已取消' + }; + } + + onProgress?.({ + status: this._getStatusForStep(step.id), + message: step.name, + progress: Math.round((i / totalSteps) * 100), + currentStep: i + 1, + totalSteps, + warnings + }); + + await step.execute(context); + } + + // Get output stats | 获取输出统计 + let stats: BuildResult['stats'] | undefined; + if (this._fileSystem) { + try { + const totalSize = await this._fileSystem.getDirectorySize(config.outputPath); + stats = { + totalSize, + jsSize: 0, + wasmSize: 0, + assetsSize: 0 + }; + } catch { + // Ignore stats error | 忽略统计错误 + } + } + + onProgress?.({ + status: BuildStatus.Completed, + message: 'Build completed | 构建完成', + progress: 100, + currentStep: totalSteps, + totalSteps, + warnings + }); + + return { + success: true, + platform: config.platform, + outputPath: config.outputPath, + duration: Date.now() - startTime, + outputFiles, + warnings, + stats + }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + + onProgress?.({ + status: BuildStatus.Failed, + message: 'Build failed | 构建失败', + progress: 0, + currentStep: 0, + totalSteps, + warnings, + error: errorMessage + }); + + return { + success: false, + platform: config.platform, + outputPath: config.outputPath, + duration: Date.now() - startTime, + outputFiles, + warnings, + error: errorMessage + }; + } + } + + /** + * Check availability. + * 检查可用性。 + */ + async checkAvailability(): Promise<{ available: boolean; reason?: string }> { + // TODO: Check if WeChat DevTools is installed | 检查微信开发者工具是否安装 + return { available: true }; + } + + /** + * Get build status for step. + * 获取步骤的构建状态。 + */ + private _getStatusForStep(stepId: string): BuildStatus { + switch (stepId) { + case 'prepare': + return BuildStatus.Preparing; + case 'compile': + case 'bundle-runtime': + return BuildStatus.Compiling; + case 'copy-wasm': + case 'copy-assets': + return BuildStatus.Copying; + case 'generate-game-json': + case 'generate-game-js': + case 'generate-project-config': + case 'split-subpackages': + case 'optimize': + return BuildStatus.PostProcessing; + default: + return BuildStatus.Compiling; + } + } + + // ==================== Build Step Implementations | 构建步骤实现 ==================== + + /** + * Prepare output directory. + * 准备输出目录。 + */ + private async _prepareOutputDir(context: BuildContext): Promise { + const fs = context.data.get('fileSystem') as IBuildFileSystem | undefined; + + if (fs) { + await fs.prepareBuildDirectory(context.outputDir); + console.log('[WeChatBuild] Prepared output directory | 准备输出目录:', context.outputDir); + } else { + console.warn('[WeChatBuild] No file system service, skipping | 无文件系统服务,跳过'); + } + } + + /** + * Compile TypeScript. + * 编译 TypeScript。 + */ + private async _compileTypeScript(context: BuildContext): Promise { + const fs = context.data.get('fileSystem') as IBuildFileSystem | undefined; + const wxConfig = context.config as WeChatBuildConfig; + + if (!fs) { + console.warn('[WeChatBuild] No file system service, skipping | 无文件系统服务,跳过'); + return; + } + + // Find user script entry point | 查找用户脚本入口点 + const scriptsDir = `${context.projectRoot}/scripts`; + const hasScripts = await fs.pathExists(scriptsDir); + + if (!hasScripts) { + console.log('[WeChatBuild] No scripts directory found | 未找到脚本目录'); + return; + } + + // Find entry file | 查找入口文件 + const possibleEntries = ['index.ts', 'main.ts', 'game.ts', 'index.js', 'main.js']; + let entryFile: string | null = null; + + for (const entry of possibleEntries) { + const entryPath = `${scriptsDir}/${entry}`; + if (await fs.pathExists(entryPath)) { + entryFile = entryPath; + break; + } + } + + if (!entryFile) { + console.log('[WeChatBuild] No entry file found | 未找到入口文件'); + return; + } + + // Bundle user scripts for WeChat | 为微信打包用户脚本 + const result = await fs.bundleScripts({ + entryPoints: [entryFile], + outputDir: `${context.outputDir}/libs`, + format: 'iife', // WeChat uses iife format | 微信使用 iife 格式 + bundleName: 'user-code', + minify: wxConfig.isRelease, + sourceMap: wxConfig.sourceMap, + external: ['@esengine/ecs-framework', '@esengine/core'], + projectRoot: context.projectRoot, + define: { + 'process.env.NODE_ENV': wxConfig.isRelease ? '"production"' : '"development"', + 'wx': 'wx' // WeChat global | 微信全局对象 + } + }); + + if (!result.success) { + throw new Error(`User code compilation failed | 用户代码编译失败: ${result.error}`); + } + + result.warnings.forEach(w => context.addWarning(w)); + console.log('[WeChatBuild] Compiled TypeScript | 编译 TypeScript:', result.outputFile); + } + + /** + * Bundle runtime. + * 打包运行时。 + */ + private async _bundleRuntime(context: BuildContext): Promise { + const fs = context.data.get('fileSystem') as IBuildFileSystem | undefined; + + if (!fs) { + console.warn('[WeChatBuild] No file system service, skipping | 无文件系统服务,跳过'); + return; + } + + // Copy pre-built runtime files with WeChat adapter | 复制带微信适配器的预构建运行时文件 + const runtimeSrc = `${context.projectRoot}/node_modules/@esengine/platform-wechat/dist`; + const runtimeDst = `${context.outputDir}/libs`; + + const hasWxRuntime = await fs.pathExists(runtimeSrc); + if (hasWxRuntime) { + const count = await fs.copyDirectory(runtimeSrc, runtimeDst, ['*.js']); + console.log(`[WeChatBuild] Copied WeChat runtime | 复制微信运行时: ${count} files`); + } else { + // Fallback to standard runtime | 回退到标准运行时 + const stdRuntimeSrc = `${context.projectRoot}/node_modules/@esengine/ecs-framework/dist`; + const hasStdRuntime = await fs.pathExists(stdRuntimeSrc); + if (hasStdRuntime) { + const count = await fs.copyDirectory(stdRuntimeSrc, runtimeDst, ['*.js']); + console.log(`[WeChatBuild] Copied standard runtime | 复制标准运行时: ${count} files`); + context.addWarning('Using standard runtime, some WeChat-specific features may not work | 使用标准运行时,部分微信特有功能可能不可用'); + } else { + context.addWarning('Runtime not found | 未找到运行时'); + } + } + } + + /** + * Copy WASM files. + * 复制 WASM 文件。 + * + * This is a critical step for WeChat MiniGame build. + * 这是微信小游戏构建的关键步骤。 + */ + private async _copyWasmFiles(context: BuildContext): Promise { + const fs = context.data.get('fileSystem') as IBuildFileSystem | undefined; + const wasmFiles = context.data.get('wasmFiles') as WasmFileConfig[]; + + if (!fs) { + console.warn('[WeChatBuild] No file system service, skipping | 无文件系统服务,跳过'); + return; + } + + console.log('[WeChatBuild] Copying WASM files | 复制 WASM 文件:'); + + for (const file of wasmFiles) { + const sourcePath = `${context.projectRoot}/node_modules/${file.source}`; + const targetPath = `${context.outputDir}/${file.target}`; + + const exists = await fs.pathExists(sourcePath); + if (exists) { + await fs.copyFile(sourcePath, targetPath); + console.log(` - ${file.description}: ${file.source} -> ${file.target}`); + } else { + context.addWarning(`WASM file not found | 未找到 WASM 文件: ${file.source}`); + } + } + + // Copy engine WASM | 复制引擎 WASM + const engineWasmSrc = `${context.projectRoot}/node_modules/@esengine/es-engine/pkg`; + const hasEngineWasm = await fs.pathExists(engineWasmSrc); + if (hasEngineWasm) { + const count = await fs.copyDirectory(engineWasmSrc, `${context.outputDir}/wasm`, ['*.wasm']); + console.log(`[WeChatBuild] Copied engine WASM | 复制引擎 WASM: ${count} files`); + } + + context.addWarning( + 'iOS WeChat requires WXWebAssembly for loading WASM | iOS 微信需要使用 WXWebAssembly 加载 WASM' + ); + } + + /** + * Copy asset files. + * 复制资源文件。 + */ + private async _copyAssets(context: BuildContext): Promise { + const fs = context.data.get('fileSystem') as IBuildFileSystem | undefined; + + if (!fs) { + console.warn('[WeChatBuild] No file system service, skipping | 无文件系统服务,跳过'); + return; + } + + // Copy scenes | 复制场景 + const scenesDir = `${context.projectRoot}/scenes`; + if (await fs.pathExists(scenesDir)) { + const count = await fs.copyDirectory(scenesDir, `${context.outputDir}/scenes`); + console.log(`[WeChatBuild] Copied scenes | 复制场景: ${count} files`); + } + + // Copy assets | 复制资源 + const assetsDir = `${context.projectRoot}/assets`; + if (await fs.pathExists(assetsDir)) { + const count = await fs.copyDirectory(assetsDir, `${context.outputDir}/assets`); + console.log(`[WeChatBuild] Copied assets | 复制资源: ${count} files`); + } + } + + /** + * Generate game.json. + * 生成 game.json。 + */ + private async _generateGameJson(context: BuildContext): Promise { + const fs = context.data.get('fileSystem') as IBuildFileSystem | undefined; + const wxConfig = context.config as WeChatBuildConfig; + + if (!fs) { + console.warn('[WeChatBuild] No file system service, skipping | 无文件系统服务,跳过'); + return; + } + + const gameJson: Record = { + deviceOrientation: 'portrait', + showStatusBar: false, + networkTimeout: { + request: 60000, + connectSocket: 60000, + uploadFile: 60000, + downloadFile: 60000 + }, + // Declare WebAssembly usage | 声明使用 WebAssembly + enableWebAssembly: true + }; + + if (wxConfig.useSubpackages) { + gameJson.subpackages = []; + } + + if (wxConfig.usePlugins) { + gameJson.plugins = {}; + } + + const jsonContent = JSON.stringify(gameJson, null, 2); + await fs.writeJsonFile(`${context.outputDir}/game.json`, jsonContent); + console.log('[WeChatBuild] Generated game.json | 生成 game.json'); + } + + /** + * Generate game.js. + * 生成 game.js。 + */ + private async _generateGameJs(context: BuildContext): Promise { + const fs = context.data.get('fileSystem') as IBuildFileSystem | undefined; + + if (!fs) { + console.warn('[WeChatBuild] No file system service, skipping | 无文件系统服务,跳过'); + return; + } + + const gameJs = `/** + * WeChat MiniGame entry point. + * 微信小游戏入口。 + * + * Auto-generated, do not modify manually. + * 自动生成,请勿手动修改。 + */ + +// WeChat adapter | 微信适配器 +require('./libs/weapp-adapter.js'); + +// Load runtime | 加载运行时 +require('./libs/esengine-runtime.js'); + +// Load user code | 加载用户代码 +require('./libs/user-code.js'); + +// Initialize game | 初始化游戏 +(async function() { + try { + // Load WASM (use WXWebAssembly on iOS) | 加载 WASM(iOS 上使用 WXWebAssembly) + const isIOS = wx.getSystemInfoSync().platform === 'ios'; + if (isIOS) { + // iOS uses WXWebAssembly | iOS 使用 WXWebAssembly + await ECS.initWasm('./wasm/es_engine_bg.wasm', { useWXWebAssembly: true }); + } else { + await ECS.initWasm('./wasm/es_engine_bg.wasm'); + } + + // Create runtime | 创建运行时 + const canvas = wx.createCanvas(); + const runtime = ECS.createRuntime({ + canvas: canvas, + platform: 'wechat' + }); + + // Load scene and start | 加载场景并启动 + await runtime.loadScene('./scenes/main.ecs'); + runtime.start(); + + console.log('[Game] Started successfully | 游戏启动成功'); + } catch (error) { + console.error('[Game] Failed to start | 启动失败:', error); + } +})(); +`; + + await fs.writeFile(`${context.outputDir}/game.js`, gameJs); + console.log('[WeChatBuild] Generated game.js | 生成 game.js'); + } + + /** + * Generate project.config.json. + * 生成 project.config.json。 + */ + private async _generateProjectConfig(context: BuildContext): Promise { + const fs = context.data.get('fileSystem') as IBuildFileSystem | undefined; + const wxConfig = context.config as WeChatBuildConfig; + + if (!fs) { + console.warn('[WeChatBuild] No file system service, skipping | 无文件系统服务,跳过'); + return; + } + + const projectConfig = { + description: 'ESEngine Game', + packOptions: { + ignore: [], + include: [] + }, + setting: { + urlCheck: false, + es6: true, + enhance: true, + postcss: false, + preloadBackgroundData: false, + minified: wxConfig.isRelease, + newFeature: true, + autoAudits: false, + coverView: true, + showShadowRootInWxmlPanel: true, + scopeDataCheck: false, + checkInvalidKey: true, + checkSiteMap: true, + uploadWithSourceMap: !wxConfig.isRelease, + compileHotReLoad: false, + babelSetting: { + ignore: [], + disablePlugins: [], + outputPath: '' + } + }, + compileType: 'game', + libVersion: '2.25.0', + appid: wxConfig.appId, + projectname: 'ESEngine Game', + condition: {} + }; + + const jsonContent = JSON.stringify(projectConfig, null, 2); + await fs.writeJsonFile(`${context.outputDir}/project.config.json`, jsonContent); + console.log('[WeChatBuild] Generated project.config.json | 生成 project.config.json'); + } + + /** + * Process subpackages. + * 分包处理。 + */ + private async _splitSubpackages(context: BuildContext): Promise { + const wxConfig = context.config as WeChatBuildConfig; + console.log('[WeChatBuild] Processing subpackages, limit | 分包处理,限制:', wxConfig.mainPackageLimit, 'KB'); + + // TODO: Implement automatic subpackage splitting based on file sizes + // 实现基于文件大小的自动分包 + context.addWarning('Subpackage splitting is not fully implemented yet | 分包功能尚未完全实现'); + } + + /** + * Optimize and compress. + * 优化压缩。 + */ + private async _optimize(context: BuildContext): Promise { + console.log('[WeChatBuild] Optimization complete | 优化完成'); + // Minification is done during bundling | 压缩在打包时已完成 + } +} diff --git a/packages/editor-core/src/Services/Build/pipelines/WebBuildPipeline.ts b/packages/editor-core/src/Services/Build/pipelines/WebBuildPipeline.ts new file mode 100644 index 00000000..91a6bc37 --- /dev/null +++ b/packages/editor-core/src/Services/Build/pipelines/WebBuildPipeline.ts @@ -0,0 +1,1372 @@ +/** + * Web Platform Build Pipeline. + * Web 平台构建管线。 + * + * Packages the project as a web application that can run in browsers. + * 将项目打包为可在浏览器中运行的 Web 应用。 + */ + +import type { + IBuildPipeline, + BuildConfig, + BuildResult, + BuildProgress, + BuildStep, + BuildContext, + WebBuildConfig +} from '../IBuildPipeline'; +import { BuildPlatform, BuildStatus } from '../IBuildPipeline'; +import type { ModuleManifest } from '../../Module/ModuleTypes'; + +/** + * Build file system interface. + * 构建文件系统接口。 + * + * This interface is implemented by the editor-app's BuildFileSystemService. + * 此接口由 editor-app 的 BuildFileSystemService 实现。 + */ +export interface IBuildFileSystem { + prepareBuildDirectory(outputPath: string): Promise; + copyDirectory(src: string, dst: string, patterns?: string[]): Promise; + bundleScripts(options: { + entryPoints: string[]; + outputDir: string; + format: 'esm' | 'iife'; + bundleName: string; + minify: boolean; + sourceMap: boolean; + external: string[]; + projectRoot: string; + define?: Record; + }): Promise<{ + success: boolean; + outputFile?: string; + outputSize?: number; + error?: string; + warnings: string[]; + }>; + generateHtml( + outputPath: string, + title: string, + scripts: string[], + bodyContent?: string + ): Promise; + getFileSize(filePath: string): Promise; + getDirectorySize(dirPath: string): Promise; + writeJsonFile(filePath: string, content: string): Promise; + listFilesByExtension(dirPath: string, extensions: string[], recursive?: boolean): Promise; + copyFile(src: string, dst: string): Promise; + pathExists(path: string): Promise; + readFile(path: string): Promise; + writeFile(path: string, content: string): Promise; + readJson(path: string): Promise; + createDirectory(path: string): Promise; + /** Read binary file as base64 | 读取二进制文件为 base64 */ + readBinaryFileAsBase64?(path: string): Promise; +} + +/** + * Web Platform Build Pipeline. + * Web 平台构建管线。 + * + * Build steps: + * 构建步骤: + * 1. Prepare output directory | 准备输出目录 + * 2. Compile TypeScript | 编译 TypeScript + * 3. Bundle runtime | 打包运行时 + * 4. Copy asset files | 复制资源文件 + * 5. Generate HTML | 生成 HTML + * 6. Post-process optimization | 后处理优化 + */ +export class WebBuildPipeline implements IBuildPipeline { + readonly platform = BuildPlatform.Web; + readonly displayName = 'Web / H5'; + readonly description = 'Build as a web application that can run in browsers | 构建为可在浏览器中运行的 Web 应用'; + readonly icon = 'globe'; + + private _fileSystem: IBuildFileSystem | null = null; + private _enabledModules: string[] = []; + private _disabledModules: string[] = []; + private _engineModulesPath: string = ''; + + /** + * Set build file system service. + * 设置构建文件系统服务。 + * + * @param fileSystem - Build file system service | 构建文件系统服务 + */ + setFileSystem(fileSystem: IBuildFileSystem): void { + this._fileSystem = fileSystem; + } + + /** + * Set enabled modules for build (whitelist approach). + * 设置构建时启用的模块(白名单方式)。 + * + * @param modules - List of enabled module IDs | 启用的模块 ID 列表 + */ + setEnabledModules(modules: string[]): void { + this._enabledModules = modules; + } + + /** + * Set disabled modules for build (blacklist approach). + * 设置构建时禁用的模块(黑名单方式)。 + * + * @param modules - List of disabled module IDs | 禁用的模块 ID 列表 + */ + setDisabledModules(modules: string[]): void { + this._disabledModules = modules; + } + + /** + * Set engine modules path. + * 设置引擎模块路径。 + * + * @param path - Path to engine modules directory | 引擎模块目录路径 + */ + setEngineModulesPath(path: string): void { + this._engineModulesPath = path; + } + + /** + * Get default configuration. + * 获取默认配置。 + */ + getDefaultConfig(): WebBuildConfig { + return { + platform: BuildPlatform.Web, + outputPath: './dist/web', + isRelease: true, + sourceMap: false, + format: 'iife', + bundleModules: true, + generateHtml: true + }; + } + + /** + * Validate configuration. + * 验证配置。 + * + * @param config - Build configuration | 构建配置 + * @returns Validation error list | 验证错误列表 + */ + validateConfig(config: BuildConfig): string[] { + const errors: string[] = []; + const webConfig = config as WebBuildConfig; + + if (!webConfig.outputPath) { + errors.push('Output path cannot be empty | 输出路径不能为空'); + } + + if (webConfig.format !== 'iife' && webConfig.format !== 'esm') { + errors.push('Output format must be iife or esm | 输出格式必须是 iife 或 esm'); + } + + return errors; + } + + /** + * Get build steps. + * 获取构建步骤。 + * + * @param config - Build configuration | 构建配置 + * @returns Build step list | 构建步骤列表 + */ + getSteps(config: BuildConfig): BuildStep[] { + const webConfig = config as WebBuildConfig; + + const steps: BuildStep[] = [ + { + id: 'prepare', + name: 'Prepare output directory | 准备输出目录', + execute: this._prepareOutputDir.bind(this) + }, + { + id: 'compile', + name: 'Compile TypeScript | 编译 TypeScript', + execute: this._compileTypeScript.bind(this) + }, + { + id: 'bundle-runtime', + name: 'Bundle runtime | 打包运行时', + execute: this._bundleRuntime.bind(this) + }, + { + id: 'copy-assets', + name: 'Copy asset files | 复制资源文件', + execute: this._copyAssets.bind(this) + } + ]; + + if (webConfig.generateHtml) { + steps.push({ + id: 'generate-html', + name: 'Generate HTML | 生成 HTML', + execute: this._generateHtml.bind(this) + }); + } + + if (webConfig.isRelease) { + steps.push({ + id: 'optimize', + name: 'Optimize and compress | 优化压缩', + execute: this._optimize.bind(this), + optional: true + }); + } + + return steps; + } + + /** + * Execute build. + * 执行构建。 + * + * @param config - Build configuration | 构建配置 + * @param onProgress - Progress callback | 进度回调 + * @param abortSignal - Abort signal | 中止信号 + * @returns Build result | 构建结果 + */ + async build( + config: BuildConfig, + onProgress?: (progress: BuildProgress) => void, + abortSignal?: AbortSignal + ): Promise { + const startTime = Date.now(); + const warnings: string[] = []; + const outputFiles: string[] = []; + + const steps = this.getSteps(config); + const totalSteps = steps.length; + + const outputPathParts = config.outputPath.replace(/\\/g, '/').split('/'); + const buildIndex = outputPathParts.lastIndexOf('build'); + const projectRoot = buildIndex > 0 + ? outputPathParts.slice(0, buildIndex).join('/') + : '.'; + + const context: BuildContext = { + config, + projectRoot, + tempDir: `${projectRoot}/temp/build`, + outputDir: config.outputPath, + reportProgress: () => {}, + addWarning: (warning) => warnings.push(warning), + abortSignal: abortSignal || new AbortController().signal, + data: new Map() + }; + + context.data.set('fileSystem', this._fileSystem); + + try { + for (let i = 0; i < steps.length; i++) { + const step = steps[i]; + + if (abortSignal?.aborted) { + return { + success: false, + platform: config.platform, + outputPath: config.outputPath, + duration: Date.now() - startTime, + outputFiles, + warnings, + error: 'Build cancelled | 构建已取消' + }; + } + + onProgress?.({ + status: this._getStatusForStep(step.id), + message: step.name, + progress: Math.round((i / totalSteps) * 100), + currentStep: i + 1, + totalSteps, + warnings + }); + + await step.execute(context); + } + + let stats: BuildResult['stats'] | undefined; + if (this._fileSystem) { + try { + const totalSize = await this._fileSystem.getDirectorySize(config.outputPath); + stats = { totalSize, jsSize: 0, wasmSize: 0, assetsSize: 0 }; + } catch { + // Ignore + } + } + + onProgress?.({ + status: BuildStatus.Completed, + message: 'Build completed | 构建完成', + progress: 100, + currentStep: totalSteps, + totalSteps, + warnings + }); + + return { + success: true, + platform: config.platform, + outputPath: config.outputPath, + duration: Date.now() - startTime, + outputFiles, + warnings, + stats + }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + + onProgress?.({ + status: BuildStatus.Failed, + message: 'Build failed | 构建失败', + progress: 0, + currentStep: 0, + totalSteps, + warnings, + error: errorMessage + }); + + return { + success: false, + platform: config.platform, + outputPath: config.outputPath, + duration: Date.now() - startTime, + outputFiles, + warnings, + error: errorMessage + }; + } + } + + /** + * Check availability. + * 检查可用性。 + * + * Web platform is always available. + * Web 平台始终可用。 + */ + async checkAvailability(): Promise<{ available: boolean; reason?: string }> { + return { available: true }; + } + + /** + * Get build status for step. + * 获取步骤的构建状态。 + */ + private _getStatusForStep(stepId: string): BuildStatus { + switch (stepId) { + case 'prepare': + return BuildStatus.Preparing; + case 'compile': + case 'bundle-runtime': + return BuildStatus.Compiling; + case 'copy-assets': + return BuildStatus.Copying; + case 'generate-html': + case 'optimize': + return BuildStatus.PostProcessing; + default: + return BuildStatus.Compiling; + } + } + + private async _prepareOutputDir(context: BuildContext): Promise { + const fs = context.data.get('fileSystem') as IBuildFileSystem | undefined; + + if (fs) { + await fs.prepareBuildDirectory(context.outputDir); + console.log('[WebBuild] Prepared output directory | 准备输出目录:', context.outputDir); + } else { + console.warn('[WebBuild] No file system service, skipping directory preparation | 无文件系统服务,跳过目录准备'); + } + } + + private async _compileTypeScript(context: BuildContext): Promise { + const fs = context.data.get('fileSystem') as IBuildFileSystem | undefined; + const webConfig = context.config as WebBuildConfig; + + if (!fs) { + console.warn('[WebBuild] No file system service, skipping TypeScript compilation | 无文件系统服务,跳过 TypeScript 编译'); + return; + } + + const scriptsDir = `${context.projectRoot}/scripts`; + if (!await fs.pathExists(scriptsDir)) { + console.log('[WebBuild] No scripts directory, skipping user code compilation'); + return; + } + + const possibleEntries = ['index.ts', 'main.ts', 'game.ts', 'index.js', 'main.js']; + let entryFile: string | null = null; + for (const entry of possibleEntries) { + const entryPath = `${scriptsDir}/${entry}`; + if (await fs.pathExists(entryPath)) { + entryFile = entryPath; + break; + } + } + + if (!entryFile) { + console.log('[WebBuild] No entry file found in scripts directory'); + return; + } + + const result = await fs.bundleScripts({ + entryPoints: [entryFile], + outputDir: context.outputDir, + format: webConfig.format || 'iife', + bundleName: 'user-code', + minify: webConfig.isRelease, + sourceMap: webConfig.sourceMap, + external: ['@esengine/ecs-framework', '@esengine/core'], + projectRoot: context.projectRoot, + define: { + 'process.env.NODE_ENV': webConfig.isRelease ? '"production"' : '"development"' + } + }); + + if (!result.success) { + throw new Error(`User code compilation failed: ${result.error}`); + } + + result.warnings.forEach(w => context.addWarning(w)); + console.log('[WebBuild] Compiled TypeScript:', result.outputFile); + } + + /** + * Bundle runtime. + * 打包运行时。 + * + * bundleModules=true: Bundle all into runtime.browser.js + * bundleModules=false: Copy individual module files to libs/ + */ + private async _bundleRuntime(context: BuildContext): Promise { + const fs = context.data.get('fileSystem') as IBuildFileSystem | undefined; + const webConfig = context.config as WebBuildConfig; + + if (!fs) { + console.warn('[WebBuild] No file system service, skipping runtime bundling | 无文件系统服务,跳过运行时打包'); + return; + } + + const modulesPath = this._engineModulesPath || await this._findEngineModulesPath(context, fs); + if (!modulesPath) { + await this._copyPrebuiltRuntime(context, fs); + return; + } + + // Priority: disabledModules (blacklist) > enabledModules (whitelist) > defaults + let enabledModules: string[]; + const disabledModules = context.config.disabledModules?.length + ? context.config.disabledModules + : this._disabledModules; + + if (disabledModules.length > 0) { + const allModules = await this._getDefaultModules(modulesPath, fs); + enabledModules = allModules.filter(id => !disabledModules.includes(id)); + console.log(`[WebBuild] Blacklist mode, disabled ${disabledModules.length} modules`); + } else if (context.config.enabledModules?.length) { + enabledModules = context.config.enabledModules; + } else if (this._enabledModules.length > 0) { + enabledModules = this._enabledModules; + } else { + enabledModules = await this._getDefaultModules(modulesPath, fs); + } + + const modules = await this._loadModuleManifests(modulesPath, enabledModules, fs); + + if (modules.length === 0) { + console.warn('[WebBuild] No modules found, falling back to pre-built runtime'); + await this._copyPrebuiltRuntime(context, fs); + return; + } + + console.log(`[WebBuild] Building with ${modules.length} modules:`, modules.map(m => m.id).join(', ')); + context.data.set('enabledModules', modules); + + if (webConfig.bundleModules !== false) { + await this._bundleModulesIntoOne(context, fs, modules, modulesPath, webConfig); + } else { + await this._copyModulesSeparately(context, fs, modules, modulesPath); + await this._copyExternalDependencies(context, fs, modules, modulesPath); + } + + if (modules.some(m => m.requiresWasm)) { + await this._copyWasmFiles(context, fs, modulesPath); + } + + // Copy module WASM files to runtime paths (based on module.json runtimeWasmPath) + await this._copyModuleWasmToRuntimePaths(context, fs, modules, modulesPath); + } + + /** + * Copy module WASM files to their runtime paths. + * 将模块 WASM 文件复制到运行时路径。 + * + * Uses runtimeWasmPath from module.json to determine destination. + */ + private async _copyModuleWasmToRuntimePaths( + context: BuildContext, + fs: IBuildFileSystem, + modules: ModuleManifest[], + modulesPath: string | null + ): Promise { + for (const module of modules) { + if (!module.requiresWasm || !module.runtimeWasmPath || !module.wasmPaths?.length) { + continue; + } + + const wasmFileName = module.wasmPaths[0]; + const runtimePath = module.runtimeWasmPath; + const dstPath = `${context.outputDir}/${runtimePath}`; + const dstDir = dstPath.substring(0, dstPath.lastIndexOf('/')); + + // Build search paths including external dependencies + const searchPaths: string[] = []; + + // Module's own pkg directory + if (modulesPath) { + searchPaths.push(`${modulesPath}/${module.id}/pkg/${wasmFileName}`); + } + searchPaths.push(`${context.outputDir}/libs/${module.id}/pkg/${wasmFileName}`); + + // External dependencies' pkg directories + if (module.externalDependencies) { + for (const dep of module.externalDependencies) { + const depId = dep.startsWith('@esengine/') ? dep.slice(10) : dep.replace(/^@[^/]+\//, ''); + if (modulesPath) { + searchPaths.push(`${modulesPath}/${depId}/pkg/${wasmFileName}`); + } + searchPaths.push(`${context.outputDir}/libs/${depId}/pkg/${wasmFileName}`); + } + } + + let copied = false; + for (const srcPath of searchPaths) { + if (await fs.pathExists(srcPath)) { + await fs.createDirectory(dstDir); + await fs.copyFile(srcPath, dstPath); + console.log(`[WebBuild] Copied ${module.id} WASM to ${runtimePath}`); + copied = true; + break; + } + } + + if (!copied) { + console.warn(`[WebBuild] WASM not found for ${module.id}: ${wasmFileName}`); + } + } + } + + /** + * Bundle all modules into a single JS file. + * 将所有模块打包成单个 JS 文件。 + */ + private async _bundleModulesIntoOne( + context: BuildContext, + fs: IBuildFileSystem, + modules: ModuleManifest[], + modulesPath: string, + _webConfig: WebBuildConfig + ): Promise { + const libsDir = `${context.outputDir}/libs`; + await fs.createDirectory(libsDir); + + const jsContents: string[] = []; + let totalSize = 0; + + jsContents.push('/**'); + jsContents.push(' * ESEngine Runtime - Auto-bundled modules'); + jsContents.push(` * Generated: ${new Date().toISOString()}`); + jsContents.push(` * Modules: ${modules.map(m => m.id).join(', ')}`); + jsContents.push(' */'); + jsContents.push(''); + + for (const module of modules) { + const modulePath = `${modulesPath}/${module.id}/index.js`; + + if (await fs.pathExists(modulePath)) { + try { + const content = await fs.readFile(modulePath); + jsContents.push(`// === Module: ${module.id} ===`); + jsContents.push(content); + jsContents.push(''); + totalSize += content.length; + } catch (err) { + context.addWarning(`Failed to read module ${module.id}: ${err}`); + } + } else { + console.log(`[WebBuild] Module ${module.id} has no runtime (no index.js)`); + } + } + + const outputPath = `${libsDir}/runtime.browser.js`; + await fs.writeFile(outputPath, jsContents.join('\n')); + console.log(`[WebBuild] Bundled runtime: ${outputPath} (${this._formatBytes(totalSize)})`); + } + + /** + * Copy modules as separate folders: libs/{moduleId}/{moduleId}.js + * 将模块复制为单独的文件夹:libs/{moduleId}/{moduleId}.js + */ + private async _copyModulesSeparately( + context: BuildContext, + fs: IBuildFileSystem, + modules: ModuleManifest[], + modulesPath: string + ): Promise { + const libsDir = `${context.outputDir}/libs`; + await fs.createDirectory(libsDir); + const copySourceMaps = context.config.sourceMap === true; + let totalSize = 0; + const copiedModules: string[] = []; + + for (const module of modules) { + const srcModuleDir = `${modulesPath}/${module.id}`; + const srcPath = `${srcModuleDir}/index.js`; + + if (await fs.pathExists(srcPath)) { + const dstModuleDir = `${libsDir}/${module.id}`; + await fs.createDirectory(dstModuleDir); + + const dstPath = `${dstModuleDir}/${module.id}.js`; + await fs.copyFile(srcPath, dstPath); + + const size = await fs.getFileSize(srcPath); + totalSize += size; + copiedModules.push(module.id); + + if (copySourceMaps) { + const srcMapPath = `${srcPath}.map`; + if (await fs.pathExists(srcMapPath)) { + await fs.copyFile(srcMapPath, `${dstPath}.map`); + } + } + + if (module.includes && module.includes.length > 0) { + const includedFiles = await this._resolveIncludes(fs, srcModuleDir, module.includes); + for (const file of includedFiles) { + const fileName = file.split(/[/\\]/).pop() || file; + const includeDstPath = `${dstModuleDir}/${fileName}`; + await fs.copyFile(file, includeDstPath); + totalSize += await fs.getFileSize(file); + + if (copySourceMaps) { + const includeMapPath = `${file}.map`; + if (await fs.pathExists(includeMapPath)) { + await fs.copyFile(includeMapPath, `${includeDstPath}.map`); + } + } + } + } + + // Copy pkg JS files (skip WASM if runtimeWasmPath is set) + const pkgSrcDir = `${srcModuleDir}/pkg`; + if (await fs.pathExists(pkgSrcDir)) { + const pkgDstDir = `${dstModuleDir}/pkg`; + await fs.createDirectory(pkgDstDir); + const pkgFiles = await fs.listFilesByExtension(pkgSrcDir, ['js'], false); + for (const pkgFile of pkgFiles) { + const fileName = pkgFile.split(/[/\\]/).pop() || ''; + await fs.copyFile(pkgFile, `${pkgDstDir}/${fileName}`); + } + } + + // Copy WASM only if module doesn't have runtimeWasmPath (will be copied to that location instead) + if (module.requiresWasm && module.wasmPaths && module.wasmPaths.length > 0 && !module.runtimeWasmPath) { + for (const wasmRelPath of module.wasmPaths) { + const wasmFileName = wasmRelPath.split(/[/\\]/).pop() || wasmRelPath; + const possiblePaths = [ + `${srcModuleDir}/${wasmRelPath}`, + `${srcModuleDir}/${wasmFileName}`, + `${srcModuleDir}/pkg/${wasmFileName}`, + ]; + for (const wasmSrcPath of possiblePaths) { + if (await fs.pathExists(wasmSrcPath)) { + const wasmDstDir = `${dstModuleDir}/pkg`; + await fs.createDirectory(wasmDstDir); + await fs.copyFile(wasmSrcPath, `${wasmDstDir}/${wasmFileName}`); + console.log(`[WebBuild] Copied WASM to ${module.id}/pkg/: ${wasmFileName}`); + break; + } + } + } + } + } + } + + console.log(`[WebBuild] Copied ${copiedModules.length} modules (${this._formatBytes(totalSize)})`); + } + + private async _resolveIncludes( + fs: IBuildFileSystem, + moduleDir: string, + includes: string[] + ): Promise { + const resolvedFiles: string[] = []; + const allJsFiles = await fs.listFilesByExtension(moduleDir, ['js'], false); + + for (const pattern of includes) { + const regexPattern = pattern + .replace(/\./g, '\\.') + .replace(/\*/g, '.*') + .replace(/\?/g, '.'); + const regex = new RegExp(`^${regexPattern}$`); + + for (const filePath of allJsFiles) { + const fileName = filePath.split(/[/\\]/).pop() || ''; + if (regex.test(fileName) && !resolvedFiles.includes(filePath)) { + resolvedFiles.push(filePath); + } + } + } + + return resolvedFiles; + } + + /** + * Copy external dependencies (e.g., @esengine/rapier2d) to output directory. + * 复制外部依赖(如 @esengine/rapier2d)到输出目录。 + */ + private async _copyExternalDependencies( + context: BuildContext, + fs: IBuildFileSystem, + modules: ModuleManifest[], + modulesPath: string + ): Promise { + const externalDeps = new Set(); + for (const m of modules) { + if (m.externalDependencies) { + for (const dep of m.externalDependencies) { + externalDeps.add(dep); + } + } + } + + if (externalDeps.size === 0) return; + + const libsDir = `${context.outputDir}/libs`; + await fs.createDirectory(libsDir); + const copySourceMaps = context.config.sourceMap === true; + let totalSize = 0; + const copiedDeps: string[] = []; + + for (const dep of externalDeps) { + const depId = dep.startsWith('@esengine/') ? dep.slice(10) : dep.replace(/^@[^/]+\//, ''); + const srcModuleDir = `${modulesPath}/${depId}`; + const srcPath = `${srcModuleDir}/index.js`; + + if (await fs.pathExists(srcPath)) { + const dstModuleDir = `${libsDir}/${depId}`; + await fs.createDirectory(dstModuleDir); + + const dstPath = `${dstModuleDir}/${depId}.js`; + await fs.copyFile(srcPath, dstPath); + + const size = await fs.getFileSize(srcPath); + totalSize += size; + copiedDeps.push(depId); + + if (copySourceMaps) { + const srcMapPath = `${srcPath}.map`; + if (await fs.pathExists(srcMapPath)) { + await fs.copyFile(srcMapPath, `${dstPath}.map`); + } + } + + // Copy pkg directory for WASM bindings (../pkg relative import) + // Only copy JS files, skip WASM (managed by runtimeWasmPath) + const pkgSrcDir = `${srcModuleDir}/pkg`; + if (await fs.pathExists(pkgSrcDir)) { + const pkgDstDir = `${dstModuleDir}/pkg`; + await fs.createDirectory(pkgDstDir); + const pkgJsFiles = await fs.listFilesByExtension(pkgSrcDir, ['js'], false); + for (const pkgFile of pkgJsFiles) { + const fileName = pkgFile.split(/[/\\]/).pop() || ''; + await fs.copyFile(pkgFile, `${pkgDstDir}/${fileName}`); + } + } + + console.log(`[WebBuild] Copied external: ${depId} (${this._formatBytes(size)})`); + } else { + console.warn(`[WebBuild] External dependency not found: ${dep}`); + } + } + + if (copiedDeps.length > 0) { + console.log(`[WebBuild] Copied ${copiedDeps.length} external dependencies`); + } + } + + private async _findEngineModulesPath( + context: BuildContext, + fs: IBuildFileSystem + ): Promise { + const editorPaths = [ + 'C:/Program Files/ESEngine Editor/engine', + this._engineModulesPath, + `${context.projectRoot}/node_modules/@esengine` + ].filter(Boolean) as string[]; + + for (const basePath of editorPaths) { + const indexPath = `${basePath}/index.json`; + if (await fs.pathExists(indexPath)) { + console.log(`[WebBuild] Found engine modules at: ${basePath}`); + return basePath; + } + const corePath = `${basePath}/core/module.json`; + if (await fs.pathExists(corePath)) { + console.log(`[WebBuild] Found engine modules at: ${basePath}`); + return basePath; + } + } + + console.warn('[WebBuild] Engine modules path not found'); + return null; + } + + private async _getDefaultModules( + modulesPath: string, + fs: IBuildFileSystem + ): Promise { + const indexPath = `${modulesPath}/index.json`; + if (await fs.pathExists(indexPath)) { + try { + const indexData = await fs.readJson<{ + modules: Array<{ id: string; hasRuntime?: boolean; isCore?: boolean }>; + }>(indexPath); + const moduleIds = indexData.modules.map(m => m.id); + console.log(`[WebBuild] Found ${moduleIds.length} modules from index.json`); + return moduleIds; + } catch (err) { + console.warn('[WebBuild] Failed to read index.json:', err); + } + } + + // Fallback to core modules only + const available: string[] = []; + const coreModules = ['core', 'math', 'engine-core', 'asset-system']; + for (const id of coreModules) { + const manifestPath = `${modulesPath}/${id}/module.json`; + if (await fs.pathExists(manifestPath)) { + available.push(id); + } + } + return available; + } + + private async _loadModuleManifests( + modulesPath: string, + moduleIds: string[], + fs: IBuildFileSystem + ): Promise { + const manifests: ModuleManifest[] = []; + + for (const id of moduleIds) { + const manifestPath = `${modulesPath}/${id}/module.json`; + try { + if (await fs.pathExists(manifestPath)) { + const manifest = await fs.readJson(manifestPath); + manifests.push(manifest); + } + } catch (error) { + console.warn(`[WebBuild] Failed to load module manifest: ${id}`, error); + } + } + + return this._sortModulesByDependencies(manifests); + } + + private _sortModulesByDependencies(modules: ModuleManifest[]): ModuleManifest[] { + const sorted: ModuleManifest[] = []; + const visited = new Set(); + const moduleMap = new Map(modules.map(m => [m.id, m])); + + const visit = (module: ModuleManifest) => { + if (visited.has(module.id)) return; + visited.add(module.id); + for (const depId of module.dependencies) { + const dep = moduleMap.get(depId); + if (dep) visit(dep); + } + sorted.push(module); + }; + + for (const module of modules) { + visit(module); + } + return sorted; + } + + private async _copyPrebuiltRuntime( + context: BuildContext, + fs: IBuildFileSystem + ): Promise { + const possiblePaths = [ + 'C:/Program Files/ESEngine Editor/runtime.browser.js', + `${context.projectRoot}/node_modules/@esengine/platform-web/dist/runtime.browser.js` + ]; + + for (const srcPath of possiblePaths) { + if (await fs.pathExists(srcPath)) { + await fs.copyFile(srcPath, `${context.outputDir}/libs/runtime.browser.js`); + console.log('[WebBuild] Copied pre-built runtime'); + await this._copyWasmFiles(context, fs, null); + return; + } + } + + context.addWarning('runtime.browser.js not found'); + } + + /** + * Copy core engine WASM to libs/es-engine/. + * 将核心引擎 WASM 复制到 libs/es-engine/。 + */ + private async _copyWasmFiles( + context: BuildContext, + fs: IBuildFileSystem, + modulesPath: string | null + ): Promise { + const esEngineDir = `${context.outputDir}/libs/es-engine`; + await fs.createDirectory(esEngineDir); + + const engineWasmPaths = [ + 'C:/Program Files/ESEngine Editor', + modulesPath ? `${modulesPath}/../wasm` : null, + this._engineModulesPath ? `${this._engineModulesPath}/../../../engine/pkg` : null, + `${context.projectRoot}/node_modules/@esengine/engine/pkg`, + `${context.projectRoot}/node_modules/@esengine/ecs-engine-bindgen/wasm` + ].filter(Boolean) as string[]; + + for (const wasmSrc of engineWasmPaths) { + if (await fs.pathExists(wasmSrc)) { + const count = await fs.copyDirectory(wasmSrc, esEngineDir, ['*.wasm', 'es_engine.js', 'es_engine_bg.js']); + if (count > 0) { + console.log(`[WebBuild] Copied engine WASM: ${count} files`); + return; + } + } + } + + context.addWarning('Engine WASM files not found'); + } + + private _formatBytes(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; + } + + private async _copyAssets(context: BuildContext): Promise { + const fs = context.data.get('fileSystem') as IBuildFileSystem | undefined; + + if (!fs) { + console.warn('[WebBuild] No file system service, skipping asset copying'); + return; + } + + const assetPatterns = [ + '*.png', '*.jpg', '*.jpeg', '*.gif', '*.webp', '*.svg', + '*.mp3', '*.wav', '*.ogg', '*.m4a', + '*.json', '*.ecs', '*.ecs.bin', + '*.tilemap.json', '*.tileset.json', + '*.ttf', '*.otf', '*.woff', '*.woff2', + '*.glsl', '*.vert', '*.frag', + '*.btree', '*.bp', '*.mat', '*.shader' + ]; + + const assetsDir = `${context.projectRoot}/assets`; + if (await fs.pathExists(assetsDir)) { + const count = await fs.copyDirectory(assetsDir, `${context.outputDir}/assets`, assetPatterns); + console.log(`[WebBuild] Copied assets: ${count} files`); + } + + const scenesDir = `${context.projectRoot}/scenes`; + if (await fs.pathExists(scenesDir)) { + const count = await fs.copyDirectory(scenesDir, `${context.outputDir}/scenes`, ['*.ecs', '*.ecs.bin', '*.json']); + console.log(`[WebBuild] Copied scenes: ${count} files`); + } + + if (context.config.scenes && context.config.scenes.length > 0) { + console.log(`[WebBuild] Configured scenes: ${context.config.scenes.join(', ')}`); + } + + // Generate asset catalog + await this._generateAssetCatalog(context, fs); + } + + /** + * Generate asset-catalog.json for runtime asset loading. + * 生成 asset-catalog.json 用于运行时资产加载。 + */ + private async _generateAssetCatalog( + context: BuildContext, + fs: IBuildFileSystem + ): Promise { + const assetsDir = `${context.outputDir}/assets`; + if (!await fs.pathExists(assetsDir)) { + console.log('[WebBuild] No assets directory, skipping catalog generation'); + return; + } + + const assetExtensions = [ + 'png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', + 'mp3', 'wav', 'ogg', 'm4a', + 'json', 'ecs', + 'ttf', 'otf', 'woff', 'woff2', + 'glsl', 'vert', 'frag', + 'btree', 'bp', 'mat', 'shader' + ]; + + const allFiles = await fs.listFilesByExtension(assetsDir, assetExtensions, true); + + const entries: Record = {}; + + for (const filePath of allFiles) { + const relativePath = filePath + .replace(context.outputDir, '') + .replace(/\\/g, '/') + .replace(/^\//, ''); + + const ext = filePath.split('.').pop()?.toLowerCase() || ''; + const type = this._getAssetType(ext); + + // Use path as pseudo-GUID for now (can be enhanced with real GUID support) + const guid = relativePath.replace(/[^a-zA-Z0-9]/g, '-'); + + let size = 0; + try { + size = await fs.getFileSize(filePath); + } catch { + // Ignore size errors + } + + entries[guid] = { + guid, + path: relativePath, + type, + size, + hash: '' // Can be enhanced with real hash + }; + } + + const catalog = { + version: '1.0', + createdAt: Date.now(), + entries + }; + + await fs.writeFile( + `${context.outputDir}/asset-catalog.json`, + JSON.stringify(catalog, null, 2) + ); + + console.log(`[WebBuild] Generated asset catalog: ${Object.keys(entries).length} entries`); + } + + /** + * Get asset type from file extension. + * 根据文件扩展名获取资产类型。 + */ + private _getAssetType(ext: string): string { + const typeMap: Record = { + png: 'texture', jpg: 'texture', jpeg: 'texture', + gif: 'texture', webp: 'texture', svg: 'texture', + mp3: 'audio', wav: 'audio', ogg: 'audio', m4a: 'audio', + json: 'json', ecs: 'scene', + ttf: 'font', otf: 'font', woff: 'font', woff2: 'font', + glsl: 'shader', vert: 'shader', frag: 'shader', + btree: 'behavior-tree', bp: 'blueprint', + mat: 'material', shader: 'shader' + }; + return typeMap[ext] || 'unknown'; + } + + private async _generateHtml(context: BuildContext): Promise { + const fs = context.data.get('fileSystem') as IBuildFileSystem | undefined; + const webConfig = context.config as WebBuildConfig; + const enabledModules = context.data.get('enabledModules') as ModuleManifest[] | undefined; + + if (!fs) { + console.warn('[WebBuild] No file system service, skipping HTML generation'); + return; + } + + let mainScenePath = './assets/Untitled.ecs'; + if (context.config.scenes && context.config.scenes.length > 0) { + mainScenePath = context.config.scenes[0]; + if (mainScenePath.includes(context.projectRoot)) { + mainScenePath = './' + mainScenePath.replace(context.projectRoot, '').replace(/^[/\\]+/, ''); + } + } else { + const sceneFiles = await fs.listFilesByExtension(`${context.outputDir}/assets`, ['.ecs']); + if (sceneFiles.length > 0) { + mainScenePath = './assets/' + sceneFiles[0].split(/[/\\]/).pop(); + } + } + + const esEngineDir = `${context.outputDir}/libs/es-engine`; + const hasWasm = await fs.pathExists(esEngineDir); + let wasmFileName = 'es_engine_bg.wasm'; + if (hasWasm) { + const wasmFiles = await fs.listFilesByExtension(esEngineDir, ['.wasm']); + if (wasmFiles.length > 0) { + wasmFileName = wasmFiles[0].split(/[/\\]/).pop() || wasmFileName; + } + } + + const useBundledModules = webConfig.bundleModules !== false; + let importMapScript = ''; + let pluginImportCode = ''; + + if (!useBundledModules && enabledModules) { + const imports: Record = {}; + for (const m of enabledModules) { + // All modules use same pattern: libs/{id}/{id}.js + imports[`@esengine/${m.id}`] = `./libs/${m.id}/${m.id}.js`; + if (m.name && m.name !== `@esengine/${m.id}`) { + imports[m.name] = imports[`@esengine/${m.id}`]; + } + } + + const externalDeps = new Set(); + for (const m of enabledModules) { + if (m.externalDependencies) { + for (const dep of m.externalDependencies) { + externalDeps.add(dep); + } + } + } + for (const dep of externalDeps) { + const depId = dep.startsWith('@esengine/') ? dep.slice(10) : dep.replace(/^@[^/]+\//, ''); + imports[dep] = `./libs/${depId}/${depId}.js`; + } + + importMapScript = ` `; + + const pluginModules = enabledModules.filter(m => + m.pluginExport && m.id !== 'core' && m.id !== 'math' && m.id !== 'platform-web' && m.id !== 'runtime-core' + ); + if (pluginModules.length > 0) { + pluginImportCode = pluginModules.map(m => + ` try { + const { ${m.pluginExport} } = await import('${m.name}'); + runtime.registerPlugin(${m.pluginExport}); + } catch (e) { + console.warn('[Game] Failed to load plugin ${m.id}:', e.message); + }` + ).join('\n'); + } + } + const htmlContent = ` + + + + + Game +${importMapScript} + + + +
+
+
Loading...
+
+
+

Failed to start game

+

+        
+
+ + +${useBundledModules ? ` + + + ` : ` + `} + +`; + + await fs.writeFile(`${context.outputDir}/index.html`, htmlContent); + console.log(`[WebBuild] Generated HTML (${useBundledModules ? 'bundled' : 'separate modules'})`); + } + + private async _optimize(_context: BuildContext): Promise { + console.log('[WebBuild] Optimization complete (minification done during bundling)'); + } +} diff --git a/packages/editor-core/src/Services/Build/pipelines/index.ts b/packages/editor-core/src/Services/Build/pipelines/index.ts new file mode 100644 index 00000000..6cb98484 --- /dev/null +++ b/packages/editor-core/src/Services/Build/pipelines/index.ts @@ -0,0 +1,7 @@ +/** + * Build Pipelines. + * 构建管线。 + */ + +export { WebBuildPipeline, type IBuildFileSystem } from './WebBuildPipeline'; +export { WeChatBuildPipeline } from './WeChatBuildPipeline'; diff --git a/packages/editor-core/src/Services/ComponentInspectorRegistry.ts b/packages/editor-core/src/Services/ComponentInspectorRegistry.ts index 631be98f..fb32eb9c 100644 --- a/packages/editor-core/src/Services/ComponentInspectorRegistry.ts +++ b/packages/editor-core/src/Services/ComponentInspectorRegistry.ts @@ -20,6 +20,12 @@ export interface ComponentInspectorContext { onAction?: (actionId: string, propertyName: string, component: Component) => void; } +/** + * Inspector render mode. + * 检查器渲染模式。 + */ +export type InspectorRenderMode = 'replace' | 'append'; + /** * 组件检查器接口 * Interface for custom component inspectors @@ -33,6 +39,12 @@ export interface IComponentInspector { readonly priority?: number; /** 目标组件类型名称列表 */ readonly targetComponents: string[]; + /** + * 渲染模式 + * - 'replace': 替换默认的 PropertyInspector(默认) + * - 'append': 追加到默认的 PropertyInspector 后面 + */ + readonly renderMode?: InspectorRenderMode; /** * 判断是否可以处理该组件 @@ -73,10 +85,12 @@ export class ComponentInspectorRegistry implements IService { } /** - * 查找可以处理指定组件的检查器 + * 查找可以处理指定组件的检查器(仅 replace 模式) + * Find inspector that can handle the component (replace mode only) */ findInspector(component: Component): IComponentInspector | undefined { const inspectors = Array.from(this.inspectors.values()) + .filter(i => i.renderMode !== 'append') .sort((a, b) => (b.priority || 0) - (a.priority || 0)); for (const inspector of inspectors) { @@ -93,14 +107,45 @@ export class ComponentInspectorRegistry implements IService { } /** - * 检查是否有自定义检查器 + * 查找所有追加模式的检查器 + * Find all append-mode inspectors for the component + */ + findAppendInspectors(component: Component): IComponentInspector[] { + const result: IComponentInspector[] = []; + const inspectors = Array.from(this.inspectors.values()) + .filter(i => i.renderMode === 'append') + .sort((a, b) => (b.priority || 0) - (a.priority || 0)); + + for (const inspector of inspectors) { + try { + if (inspector.canHandle(component)) { + result.push(inspector); + } + } catch (error) { + logger.error(`Error in canHandle for inspector ${inspector.id}:`, error); + } + } + + return result; + } + + /** + * 检查是否有自定义检查器(replace 模式) */ hasInspector(component: Component): boolean { return this.findInspector(component) !== undefined; } /** - * 渲染组件 + * 检查是否有追加检查器 + */ + hasAppendInspectors(component: Component): boolean { + return this.findAppendInspectors(component).length > 0; + } + + /** + * 渲染组件(replace 模式) + * Render component with replace-mode inspector */ render(context: ComponentInspectorContext): React.ReactElement | null { const inspector = this.findInspector(context.component); @@ -120,6 +165,38 @@ export class ComponentInspectorRegistry implements IService { } } + /** + * 渲染追加检查器 + * Render append-mode inspectors + */ + renderAppendInspectors(context: ComponentInspectorContext): React.ReactElement[] { + const inspectors = this.findAppendInspectors(context.component); + const elements: React.ReactElement[] = []; + + for (const inspector of inspectors) { + try { + elements.push( + React.createElement( + React.Fragment, + { key: inspector.id }, + inspector.render(context) + ) + ); + } catch (error) { + logger.error(`Error rendering append inspector ${inspector.id}:`, error); + elements.push( + React.createElement( + 'span', + { key: inspector.id, style: { color: '#f87171', fontStyle: 'italic' } }, + `[${inspector.name} Error]` + ) + ); + } + } + + return elements; + } + /** * 获取所有注册的检查器 */ diff --git a/packages/editor-core/src/Services/EditorViewportService.ts b/packages/editor-core/src/Services/EditorViewportService.ts new file mode 100644 index 00000000..ff76ce9b --- /dev/null +++ b/packages/editor-core/src/Services/EditorViewportService.ts @@ -0,0 +1,401 @@ +/** + * Editor Viewport Service + * 编辑器视口服务 + * + * Manages editor viewports with preview scene support and overlay rendering. + * 管理带有预览场景支持和覆盖层渲染的编辑器视口。 + */ + +import type { IViewportService, ViewportCameraConfig } from './IViewportService'; +import type { IPreviewScene } from './PreviewSceneService'; +import { PreviewSceneService } from './PreviewSceneService'; +import type { IViewportOverlay, OverlayRenderContext } from '../Rendering/IViewportOverlay'; + +/** + * Configuration for an editor viewport + * 编辑器视口配置 + */ +export interface EditorViewportConfig { + /** Unique viewport identifier | 唯一视口标识符 */ + id: string; + /** Canvas element ID | 画布元素 ID */ + canvasId: string; + /** Preview scene ID (null = main scene) | 预览场景 ID(null = 主场景) */ + previewSceneId?: string; + /** Whether to show grid | 是否显示网格 */ + showGrid?: boolean; + /** Whether to show gizmos | 是否显示辅助线 */ + showGizmos?: boolean; + /** Initial camera configuration | 初始相机配置 */ + camera?: ViewportCameraConfig; + /** Clear color | 清除颜色 */ + clearColor?: { r: number; g: number; b: number; a: number }; +} + +/** + * Viewport state + * 视口状态 + */ +interface ViewportState { + config: EditorViewportConfig; + camera: ViewportCameraConfig; + overlays: Map; + lastUpdateTime: number; +} + +/** + * Gizmo renderer interface (provided by engine) + * 辅助线渲染器接口(由引擎提供) + */ +export interface IGizmoRenderer { + addLine(x1: number, y1: number, x2: number, y2: number, color: number, thickness?: number): void; + addRect(x: number, y: number, width: number, height: number, color: number, thickness?: number): void; + addFilledRect(x: number, y: number, width: number, height: number, color: number): void; + addCircle(x: number, y: number, radius: number, color: number, thickness?: number): void; + addFilledCircle(x: number, y: number, radius: number, color: number): void; + addText?(text: string, x: number, y: number, color: number, fontSize?: number): void; + clear(): void; +} + +/** + * Editor Viewport Service Interface + * 编辑器视口服务接口 + */ +export interface IEditorViewportService { + /** + * Set the viewport service (from EngineService) + * 设置视口服务(来自 EngineService) + */ + setViewportService(service: IViewportService): void; + + /** + * Set the gizmo renderer (from EngineService) + * 设置辅助线渲染器(来自 EngineService) + */ + setGizmoRenderer(renderer: IGizmoRenderer): void; + + /** + * Register a new viewport + * 注册新视口 + */ + registerViewport(config: EditorViewportConfig): void; + + /** + * Unregister a viewport + * 注销视口 + */ + unregisterViewport(id: string): void; + + /** + * Get viewport configuration + * 获取视口配置 + */ + getViewportConfig(id: string): EditorViewportConfig | null; + + /** + * Set viewport camera + * 设置视口相机 + */ + setViewportCamera(id: string, camera: ViewportCameraConfig): void; + + /** + * Get viewport camera + * 获取视口相机 + */ + getViewportCamera(id: string): ViewportCameraConfig | null; + + /** + * Add overlay to a viewport + * 向视口添加覆盖层 + */ + addOverlay(viewportId: string, overlay: IViewportOverlay): void; + + /** + * Remove overlay from a viewport + * 从视口移除覆盖层 + */ + removeOverlay(viewportId: string, overlayId: string): void; + + /** + * Get overlay by ID + * 通过 ID 获取覆盖层 + */ + getOverlay(viewportId: string, overlayId: string): T | null; + + /** + * Get all overlays for a viewport + * 获取视口的所有覆盖层 + */ + getOverlays(viewportId: string): IViewportOverlay[]; + + /** + * Render a specific viewport + * 渲染特定视口 + */ + renderViewport(id: string): void; + + /** + * Update viewport (process preview scene systems and overlays) + * 更新视口(处理预览场景系统和覆盖层) + */ + updateViewport(id: string, deltaTime: number): void; + + /** + * Resize a viewport + * 调整视口大小 + */ + resizeViewport(id: string, width: number, height: number): void; + + /** + * Check if a viewport exists + * 检查视口是否存在 + */ + hasViewport(id: string): boolean; + + /** + * Get all viewport IDs + * 获取所有视口 ID + */ + getViewportIds(): string[]; + + /** + * Dispose the service + * 释放服务 + */ + dispose(): void; +} + +/** + * Editor Viewport Service Implementation + * 编辑器视口服务实现 + */ +export class EditorViewportService implements IEditorViewportService { + private static _instance: EditorViewportService | null = null; + + private _viewportService: IViewportService | null = null; + private _gizmoRenderer: IGizmoRenderer | null = null; + private _viewports: Map = new Map(); + private _previewSceneService = PreviewSceneService.getInstance(); + private _viewportDimensions: Map = new Map(); + + private constructor() {} + + /** + * Get singleton instance + * 获取单例实例 + */ + static getInstance(): EditorViewportService { + if (!EditorViewportService._instance) { + EditorViewportService._instance = new EditorViewportService(); + } + return EditorViewportService._instance; + } + + setViewportService(service: IViewportService): void { + this._viewportService = service; + } + + setGizmoRenderer(renderer: IGizmoRenderer): void { + this._gizmoRenderer = renderer; + } + + registerViewport(config: EditorViewportConfig): void { + if (this._viewports.has(config.id)) { + console.warn(`[EditorViewportService] Viewport "${config.id}" already registered`); + return; + } + + // Register with viewport service + if (this._viewportService) { + this._viewportService.registerViewport(config.id, config.canvasId); + this._viewportService.setViewportConfig(config.id, config.showGrid ?? false, config.showGizmos ?? false); + + if (config.camera) { + this._viewportService.setViewportCamera(config.id, config.camera); + } + } + + // Create viewport state + const state: ViewportState = { + config, + camera: config.camera ?? { x: 0, y: 0, zoom: 1 }, + overlays: new Map(), + lastUpdateTime: performance.now() + }; + + this._viewports.set(config.id, state); + } + + unregisterViewport(id: string): void { + const state = this._viewports.get(id); + if (!state) return; + + // Dispose overlays + for (const overlay of state.overlays.values()) { + overlay.dispose?.(); + } + + // Unregister from viewport service + if (this._viewportService) { + this._viewportService.unregisterViewport(id); + } + + this._viewports.delete(id); + this._viewportDimensions.delete(id); + } + + getViewportConfig(id: string): EditorViewportConfig | null { + return this._viewports.get(id)?.config ?? null; + } + + setViewportCamera(id: string, camera: ViewportCameraConfig): void { + const state = this._viewports.get(id); + if (!state) return; + + state.camera = camera; + + if (this._viewportService) { + this._viewportService.setViewportCamera(id, camera); + } + } + + getViewportCamera(id: string): ViewportCameraConfig | null { + return this._viewports.get(id)?.camera ?? null; + } + + addOverlay(viewportId: string, overlay: IViewportOverlay): void { + const state = this._viewports.get(viewportId); + if (!state) { + console.warn(`[EditorViewportService] Viewport "${viewportId}" not found`); + return; + } + + if (state.overlays.has(overlay.id)) { + console.warn(`[EditorViewportService] Overlay "${overlay.id}" already exists in viewport "${viewportId}"`); + return; + } + + state.overlays.set(overlay.id, overlay); + } + + removeOverlay(viewportId: string, overlayId: string): void { + const state = this._viewports.get(viewportId); + if (!state) return; + + const overlay = state.overlays.get(overlayId); + if (overlay) { + overlay.dispose?.(); + state.overlays.delete(overlayId); + } + } + + getOverlay(viewportId: string, overlayId: string): T | null { + const state = this._viewports.get(viewportId); + if (!state) return null; + + return (state.overlays.get(overlayId) as T) ?? null; + } + + getOverlays(viewportId: string): IViewportOverlay[] { + const state = this._viewports.get(viewportId); + if (!state) return []; + + // Return overlays sorted by priority + return Array.from(state.overlays.values()).sort((a, b) => a.priority - b.priority); + } + + updateViewport(id: string, deltaTime: number): void { + const state = this._viewports.get(id); + if (!state) return; + + // Update preview scene if configured + if (state.config.previewSceneId) { + const previewScene = this._previewSceneService.getScene(state.config.previewSceneId); + if (previewScene) { + previewScene.update(deltaTime); + } + } + + // Update overlays + for (const overlay of state.overlays.values()) { + if (overlay.visible && overlay.update) { + overlay.update(deltaTime); + } + } + + state.lastUpdateTime = performance.now(); + } + + renderViewport(id: string): void { + const state = this._viewports.get(id); + if (!state || !this._viewportService) return; + + const dimensions = this._viewportDimensions.get(id) ?? { width: 800, height: 600 }; + const dpr = window.devicePixelRatio || 1; + + // Render overlays if gizmo renderer is available + if (this._gizmoRenderer) { + const context: OverlayRenderContext = { + camera: state.camera, + viewport: dimensions, + dpr, + deltaTime: (performance.now() - state.lastUpdateTime) / 1000, + addLine: (x1, y1, x2, y2, color, thickness) => + this._gizmoRenderer!.addLine(x1, y1, x2, y2, color, thickness), + addRect: (x, y, w, h, color, thickness) => + this._gizmoRenderer!.addRect(x, y, w, h, color, thickness), + addFilledRect: (x, y, w, h, color) => + this._gizmoRenderer!.addFilledRect(x, y, w, h, color), + addCircle: (x, y, r, color, thickness) => + this._gizmoRenderer!.addCircle(x, y, r, color, thickness), + addFilledCircle: (x, y, r, color) => + this._gizmoRenderer!.addFilledCircle(x, y, r, color), + addText: this._gizmoRenderer.addText + ? (text, x, y, color, fontSize) => + this._gizmoRenderer!.addText!(text, x, y, color, fontSize) + : undefined + }; + + // Render visible overlays in priority order + const overlays = this.getOverlays(id); + for (const overlay of overlays) { + if (overlay.visible) { + overlay.render(context); + } + } + } + + // Render through viewport service + this._viewportService.renderToViewport(id); + } + + resizeViewport(id: string, width: number, height: number): void { + this._viewportDimensions.set(id, { width, height }); + + if (this._viewportService) { + this._viewportService.resizeViewport(id, width, height); + } + } + + hasViewport(id: string): boolean { + return this._viewports.has(id); + } + + getViewportIds(): string[] { + return Array.from(this._viewports.keys()); + } + + dispose(): void { + for (const id of this._viewports.keys()) { + this.unregisterViewport(id); + } + this._viewportService = null; + this._gizmoRenderer = null; + } +} + +/** + * Service identifier for dependency injection + * 依赖注入的服务标识符 + */ +export const IEditorViewportServiceIdentifier = Symbol.for('IEditorViewportService'); diff --git a/packages/editor-core/src/Services/IViewportService.ts b/packages/editor-core/src/Services/IViewportService.ts new file mode 100644 index 00000000..1eca2405 --- /dev/null +++ b/packages/editor-core/src/Services/IViewportService.ts @@ -0,0 +1,109 @@ +/** + * Viewport Service Interface + * 视口服务接口 + * + * Provides a unified interface for rendering viewports using the engine. + * Used by editor panels that need to display game content (e.g., Tilemap Editor). + * + * 提供使用引擎渲染视口的统一接口。 + * 供需要显示游戏内容的编辑器面板使用(如 Tilemap 编辑器)。 + */ + +/** + * Camera configuration for viewport + * 视口相机配置 + */ +export interface ViewportCameraConfig { + x: number; + y: number; + zoom: number; + rotation?: number; +} + +/** + * Viewport service interface + * 视口服务接口 + */ +export interface IViewportService { + /** + * Check if the renderer is initialized + * 检查渲染器是否已初始化 + */ + isInitialized(): boolean; + + /** + * Register a viewport with a canvas element + * 注册一个视口和画布元素 + * @param viewportId - Unique identifier for the viewport | 视口的唯一标识符 + * @param canvasId - ID of the canvas element | 画布元素的 ID + */ + registerViewport(viewportId: string, canvasId: string): void; + + /** + * Unregister a viewport + * 注销一个视口 + * @param viewportId - Viewport ID to unregister | 要注销的视口 ID + */ + unregisterViewport(viewportId: string): void; + + /** + * Set camera for a specific viewport + * 设置特定视口的相机 + * @param viewportId - Viewport ID | 视口 ID + * @param config - Camera configuration | 相机配置 + */ + setViewportCamera(viewportId: string, config: ViewportCameraConfig): void; + + /** + * Get camera for a specific viewport + * 获取特定视口的相机 + * @param viewportId - Viewport ID | 视口 ID + * @returns Camera configuration or null | 相机配置或 null + */ + getViewportCamera(viewportId: string): ViewportCameraConfig | null; + + /** + * Set viewport configuration (grid, gizmos visibility) + * 设置视口配置(网格、辅助线可见性) + * @param viewportId - Viewport ID | 视口 ID + * @param showGrid - Show grid | 显示网格 + * @param showGizmos - Show gizmos | 显示辅助线 + */ + setViewportConfig(viewportId: string, showGrid: boolean, showGizmos: boolean): void; + + /** + * Resize a specific viewport + * 调整特定视口的大小 + * @param viewportId - Viewport ID | 视口 ID + * @param width - New width | 新宽度 + * @param height - New height | 新高度 + */ + resizeViewport(viewportId: string, width: number, height: number): void; + + /** + * Render to a specific viewport + * 渲染到特定视口 + * @param viewportId - Viewport ID | 视口 ID + */ + renderToViewport(viewportId: string): void; + + /** + * Load a texture and return its ID + * 加载纹理并返回其 ID + * @param path - Texture path | 纹理路径 + * @returns Promise resolving to texture ID | 解析为纹理 ID 的 Promise + */ + loadTexture(path: string): Promise; + + /** + * Dispose resources (required by IService) + * 释放资源(IService 要求) + */ + dispose(): void; +} + +/** + * Service identifier for dependency injection + * 依赖注入的服务标识符 + */ +export const IViewportService_ID = Symbol.for('IViewportService'); diff --git a/packages/editor-core/src/Services/Module/ModuleRegistry.ts b/packages/editor-core/src/Services/Module/ModuleRegistry.ts new file mode 100644 index 00000000..574b6c75 --- /dev/null +++ b/packages/editor-core/src/Services/Module/ModuleRegistry.ts @@ -0,0 +1,624 @@ +/** + * Module Registry Service. + * 模块注册表服务。 + * + * Manages engine modules, their dependencies, and project configurations. + * 管理引擎模块、其依赖关系和项目配置。 + */ + +import type { + ModuleManifest, + ModuleRegistryEntry, + ModuleDisableValidation, + ProjectModuleConfig, + SceneModuleUsage, + ScriptModuleUsage +} from './ModuleTypes'; + +/** + * File system interface for module operations. + * 模块操作的文件系统接口。 + */ +export interface IModuleFileSystem { + /** Read JSON file | 读取 JSON 文件 */ + readJson(path: string): Promise; + /** Write JSON file | 写入 JSON 文件 */ + writeJson(path: string, data: unknown): Promise; + /** Check if path exists | 检查路径是否存在 */ + pathExists(path: string): Promise; + /** List files by extension | 按扩展名列出文件 */ + listFiles(dir: string, extensions: string[], recursive?: boolean): Promise; + /** Read file as text | 读取文件为文本 */ + readText(path: string): Promise; +} + +/** + * Module Registry Service. + * 模块注册表服务。 + */ +export class ModuleRegistry { + private _modules: Map = new Map(); + private _projectConfig: ProjectModuleConfig = { enabled: [] }; + private _fileSystem: IModuleFileSystem | null = null; + private _engineModulesPath: string = ''; + private _projectPath: string = ''; + + /** + * Initialize the registry. + * 初始化注册表。 + * + * @param fileSystem - File system service | 文件系统服务 + * @param engineModulesPath - Path to engine modules | 引擎模块路径 + */ + async initialize( + fileSystem: IModuleFileSystem, + engineModulesPath: string + ): Promise { + this._fileSystem = fileSystem; + this._engineModulesPath = engineModulesPath; + + // Load all module manifests | 加载所有模块清单 + await this._loadModuleManifests(); + } + + /** + * Set current project. + * 设置当前项目。 + * + * @param projectPath - Project path | 项目路径 + */ + async setProject(projectPath: string): Promise { + this._projectPath = projectPath; + await this._loadProjectConfig(); + this._updateModuleStates(); + } + + /** + * Get all registered modules. + * 获取所有注册的模块。 + */ + getAllModules(): ModuleRegistryEntry[] { + return Array.from(this._modules.values()); + } + + /** + * Get module by ID. + * 通过 ID 获取模块。 + */ + getModule(id: string): ModuleRegistryEntry | undefined { + return this._modules.get(id); + } + + /** + * Get enabled modules for current project. + * 获取当前项目启用的模块。 + */ + getEnabledModules(): ModuleRegistryEntry[] { + return this.getAllModules().filter(m => m.isEnabled); + } + + /** + * Get modules by category. + * 按分类获取模块。 + */ + getModulesByCategory(): Map { + const categories = new Map(); + + for (const module of this._modules.values()) { + const category = module.category; + if (!categories.has(category)) { + categories.set(category, []); + } + categories.get(category)!.push(module); + } + + return categories; + } + + /** + * Validate if a module can be disabled. + * 验证模块是否可以禁用。 + * + * @param moduleId - Module ID | 模块 ID + */ + async validateDisable(moduleId: string): Promise { + const module = this._modules.get(moduleId); + + if (!module) { + return { + canDisable: false, + reason: 'core', + message: `Module "${moduleId}" not found | 未找到模块 "${moduleId}"` + }; + } + + // Core modules cannot be disabled | 核心模块不能禁用 + if (module.isCore) { + return { + canDisable: false, + reason: 'core', + message: `"${module.displayName}" is a core module and cannot be disabled | "${module.displayName}" 是核心模块,不能禁用` + }; + } + + // Check if other enabled modules depend on this | 检查其他启用的模块是否依赖此模块 + const dependents = this._getEnabledDependents(moduleId); + if (dependents.length > 0) { + return { + canDisable: false, + reason: 'dependency', + message: `The following enabled modules depend on "${module.displayName}" | 以下启用的模块依赖 "${module.displayName}"`, + dependentModules: dependents + }; + } + + // Check scene usage | 检查场景使用 + const sceneUsages = await this._checkSceneUsage(moduleId); + if (sceneUsages.length > 0) { + return { + canDisable: false, + reason: 'scene-usage', + message: `"${module.displayName}" components are used in scenes | "${module.displayName}" 组件在场景中使用`, + sceneUsages + }; + } + + // Check script usage | 检查脚本使用 + const scriptUsages = await this._checkScriptUsage(moduleId); + if (scriptUsages.length > 0) { + return { + canDisable: false, + reason: 'script-usage', + message: `"${module.displayName}" is imported in scripts | "${module.displayName}" 在脚本中被导入`, + scriptUsages + }; + } + + return { canDisable: true }; + } + + /** + * Enable a module. + * 启用模块。 + * + * @param moduleId - Module ID | 模块 ID + */ + async enableModule(moduleId: string): Promise { + const module = this._modules.get(moduleId); + if (!module) return false; + + // Enable dependencies first | 先启用依赖 + for (const depId of module.dependencies) { + if (!this._projectConfig.enabled.includes(depId)) { + await this.enableModule(depId); + } + } + + // Enable this module | 启用此模块 + if (!this._projectConfig.enabled.includes(moduleId)) { + this._projectConfig.enabled.push(moduleId); + await this._saveProjectConfig(); + this._updateModuleStates(); + } + + return true; + } + + /** + * Disable a module. + * 禁用模块。 + * + * @param moduleId - Module ID | 模块 ID + * @param force - Force disable even if validation fails | 即使验证失败也强制禁用 + */ + async disableModule(moduleId: string, force: boolean = false): Promise { + if (!force) { + const validation = await this.validateDisable(moduleId); + if (!validation.canDisable) { + return false; + } + } + + const index = this._projectConfig.enabled.indexOf(moduleId); + if (index !== -1) { + this._projectConfig.enabled.splice(index, 1); + await this._saveProjectConfig(); + this._updateModuleStates(); + } + + return true; + } + + /** + * Get total build size for enabled modules (JS + WASM). + * 获取启用模块的总构建大小(JS + WASM)。 + */ + getTotalBuildSize(): { jsSize: number; wasmSize: number; total: number } { + let jsSize = 0; + let wasmSize = 0; + for (const module of this.getEnabledModules()) { + jsSize += module.jsSize || 0; + wasmSize += module.wasmSize || 0; + } + return { jsSize, wasmSize, total: jsSize + wasmSize }; + } + + /** + * Generate build entry file content. + * 生成构建入口文件内容。 + * + * Creates a dynamic entry that only imports enabled modules. + * 创建仅导入启用模块的动态入口。 + */ + generateBuildEntry(): string { + const enabledModules = this.getEnabledModules(); + const lines: string[] = [ + '// Auto-generated build entry', + '// 自动生成的构建入口', + '' + ]; + + // Export core modules | 导出核心模块 + const coreModules = enabledModules.filter(m => m.isCore); + for (const module of coreModules) { + lines.push(`export * from '${module.name}';`); + } + + lines.push(''); + + // Export optional modules | 导出可选模块 + const optionalModules = enabledModules.filter(m => !m.isCore); + for (const module of optionalModules) { + lines.push(`export * from '${module.name}';`); + } + + lines.push(''); + lines.push('// Module registration'); + lines.push('// 模块注册'); + lines.push(`import { registerModule } from '@esengine/core';`); + lines.push(''); + + // Import module classes | 导入模块类 + for (const module of optionalModules) { + const moduleName = this._toModuleClassName(module.id); + lines.push(`import { ${moduleName} } from '${module.name}';`); + } + + lines.push(''); + + // Register modules | 注册模块 + for (const module of optionalModules) { + const moduleName = this._toModuleClassName(module.id); + lines.push(`registerModule(${moduleName});`); + } + + return lines.join('\n'); + } + + // ==================== Private Methods | 私有方法 ==================== + + /** + * Load module manifests from engine modules directory. + * 从引擎模块目录加载模块清单。 + * + * Reads index.json which contains all module data including build-time calculated sizes. + * 读取 index.json,其中包含所有模块数据,包括构建时计算的大小。 + */ + private async _loadModuleManifests(): Promise { + if (!this._fileSystem) return; + + // Read module index from engine/ directory + // 从 engine/ 目录读取模块索引 + const indexPath = `${this._engineModulesPath}/index.json`; + + try { + if (await this._fileSystem.pathExists(indexPath)) { + // Load from index.json generated by copy-modules script + // 从 copy-modules 脚本生成的 index.json 加载 + const index = await this._fileSystem.readJson<{ + version: string; + generatedAt: string; + modules: Array<{ + id: string; + name: string; + displayName: string; + hasRuntime: boolean; + editorPackage?: string; + isCore: boolean; + category: string; + jsSize?: number; + requiresWasm?: boolean; + wasmSize?: number; + }>; + }>(indexPath); + + console.log(`[ModuleRegistry] Loaded ${index.modules.length} modules from index.json`); + + // Use data directly from index.json (includes jsSize, wasmSize) + // 直接使用 index.json 中的数据(包含 jsSize、wasmSize) + for (const m of index.modules) { + this._modules.set(m.id, { + id: m.id, + name: m.name, + displayName: m.displayName, + description: '', + version: '1.0.0', + category: m.category as any, + isCore: m.isCore, + defaultEnabled: m.isCore, + dependencies: [], + exports: {}, + editorPackage: m.editorPackage, + jsSize: m.jsSize, + wasmSize: m.wasmSize, + requiresWasm: m.requiresWasm, + // Registry entry fields + path: `${this._engineModulesPath}/${m.id}`, + isEnabled: false, + dependents: [], + dependenciesSatisfied: true + }); + } + + // Load full manifests for additional fields (description, dependencies, exports) + // 加载完整清单以获取额外字段(描述、依赖、导出) + for (const m of index.modules) { + const manifestPath = `${this._engineModulesPath}/${m.id}/module.json`; + try { + if (await this._fileSystem.pathExists(manifestPath)) { + const manifest = await this._fileSystem.readJson(manifestPath); + const existing = this._modules.get(m.id); + if (existing) { + // Merge manifest data but keep jsSize/wasmSize from index + // 合并清单数据但保留 index 中的 jsSize/wasmSize + existing.description = manifest.description || ''; + existing.version = manifest.version || '1.0.0'; + existing.dependencies = manifest.dependencies || []; + existing.exports = manifest.exports || {}; + existing.tags = manifest.tags; + existing.icon = manifest.icon; + existing.platforms = manifest.platforms; + existing.canContainContent = manifest.canContainContent; + } + } + } catch { + // Ignore errors loading individual manifests + } + } + } else { + console.warn(`[ModuleRegistry] index.json not found at ${indexPath}, run 'pnpm copy-modules' first`); + } + } catch (error) { + console.error('[ModuleRegistry] Failed to load index.json:', error); + } + + // Compute dependents | 计算依赖者 + this._computeDependents(); + } + + /** + * Compute which modules depend on each module. + * 计算哪些模块依赖每个模块。 + */ + private _computeDependents(): void { + for (const module of this._modules.values()) { + module.dependents = []; + } + + for (const module of this._modules.values()) { + for (const depId of module.dependencies) { + const dep = this._modules.get(depId); + if (dep) { + dep.dependents.push(module.id); + } + } + } + } + + /** + * Load project module configuration. + * 加载项目模块配置。 + */ + private async _loadProjectConfig(): Promise { + if (!this._fileSystem || !this._projectPath) return; + + const configPath = `${this._projectPath}/esengine.project.json`; + + try { + if (await this._fileSystem.pathExists(configPath)) { + const config = await this._fileSystem.readJson<{ modules?: ProjectModuleConfig }>(configPath); + this._projectConfig = config.modules || { enabled: this._getDefaultEnabledModules() }; + } else { + // Create default config | 创建默认配置 + this._projectConfig = { enabled: this._getDefaultEnabledModules() }; + } + } catch (error) { + console.error('[ModuleRegistry] Failed to load project config:', error); + this._projectConfig = { enabled: this._getDefaultEnabledModules() }; + } + } + + /** + * Save project module configuration. + * 保存项目模块配置。 + */ + private async _saveProjectConfig(): Promise { + if (!this._fileSystem || !this._projectPath) return; + + const configPath = `${this._projectPath}/esengine.project.json`; + + try { + let config: Record = {}; + + if (await this._fileSystem.pathExists(configPath)) { + config = await this._fileSystem.readJson>(configPath); + } + + config.modules = this._projectConfig; + await this._fileSystem.writeJson(configPath, config); + } catch (error) { + console.error('[ModuleRegistry] Failed to save project config:', error); + } + } + + /** + * Get default enabled modules. + * 获取默认启用的模块。 + */ + private _getDefaultEnabledModules(): string[] { + const defaults: string[] = []; + + for (const module of this._modules.values()) { + if (module.isCore || module.defaultEnabled) { + defaults.push(module.id); + } + } + + return defaults; + } + + /** + * Update module enabled states based on project config. + * 根据项目配置更新模块启用状态。 + */ + private _updateModuleStates(): void { + for (const module of this._modules.values()) { + module.isEnabled = module.isCore || this._projectConfig.enabled.includes(module.id); + module.dependenciesSatisfied = module.dependencies.every( + depId => this._projectConfig.enabled.includes(depId) || this._modules.get(depId)?.isCore + ); + } + } + + /** + * Get enabled modules that depend on the given module. + * 获取依赖给定模块的已启用模块。 + */ + private _getEnabledDependents(moduleId: string): string[] { + const module = this._modules.get(moduleId); + if (!module) return []; + + return module.dependents.filter(depId => { + const dep = this._modules.get(depId); + return dep?.isEnabled; + }); + } + + /** + * Check if module is used in any scene. + * 检查模块是否在任何场景中使用。 + */ + private async _checkSceneUsage(moduleId: string): Promise { + if (!this._fileSystem || !this._projectPath) return []; + + const module = this._modules.get(moduleId); + if (!module || !module.exports.components?.length) return []; + + const usages: SceneModuleUsage[] = []; + const sceneDir = `${this._projectPath}/assets`; + + try { + const sceneFiles = await this._fileSystem.listFiles(sceneDir, ['.ecs'], true); + + for (const scenePath of sceneFiles) { + const sceneContent = await this._fileSystem.readText(scenePath); + const componentUsages: SceneModuleUsage['components'] = []; + + for (const componentName of module.exports.components) { + // Count occurrences of component type in scene + // 计算场景中组件类型的出现次数 + const regex = new RegExp(`"type"\\s*:\\s*"${componentName}"`, 'g'); + const matches = sceneContent.match(regex); + if (matches && matches.length > 0) { + componentUsages.push({ + type: componentName, + count: matches.length + }); + } + } + + if (componentUsages.length > 0) { + usages.push({ + scenePath, + components: componentUsages + }); + } + } + } catch (error) { + console.warn('[ModuleRegistry] Failed to check scene usage:', error); + } + + return usages; + } + + /** + * Check if module is imported in any user script. + * 检查模块是否在任何用户脚本中被导入。 + */ + private async _checkScriptUsage(moduleId: string): Promise { + if (!this._fileSystem || !this._projectPath) return []; + + const module = this._modules.get(moduleId); + if (!module) return []; + + const usages: ScriptModuleUsage[] = []; + const scriptsDir = `${this._projectPath}/scripts`; + + try { + if (!await this._fileSystem.pathExists(scriptsDir)) { + return []; + } + + const scriptFiles = await this._fileSystem.listFiles(scriptsDir, ['.ts', '.tsx', '.js'], true); + + for (const scriptPath of scriptFiles) { + const content = await this._fileSystem.readText(scriptPath); + const lines = content.split('\n'); + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + + // Check for import from module package | 检查模块包的导入 + if (line.includes(module.name) && line.includes('import')) { + usages.push({ + scriptPath, + line: i + 1, + importStatement: line.trim() + }); + } + + // Check for component imports | 检查组件导入 + if (module.exports.components) { + for (const component of module.exports.components) { + if (line.includes(component) && line.includes('import')) { + usages.push({ + scriptPath, + line: i + 1, + importStatement: line.trim() + }); + } + } + } + } + } + } catch (error) { + console.warn('[ModuleRegistry] Failed to check script usage:', error); + } + + return usages; + } + + /** + * Convert module ID to class name. + * 将模块 ID 转换为类名。 + */ + private _toModuleClassName(id: string): string { + return id + .split('-') + .map(part => part.charAt(0).toUpperCase() + part.slice(1)) + .join('') + 'Module'; + } +} + +// Export singleton instance | 导出单例实例 +export const moduleRegistry = new ModuleRegistry(); diff --git a/packages/editor-core/src/Services/Module/ModuleTypes.ts b/packages/editor-core/src/Services/Module/ModuleTypes.ts new file mode 100644 index 00000000..7ea2f9af --- /dev/null +++ b/packages/editor-core/src/Services/Module/ModuleTypes.ts @@ -0,0 +1,115 @@ +/** + * Module System Types. + * 模块系统类型定义。 + * + * Re-exports core types from engine-core and defines editor-specific types. + * 从 engine-core 重新导出核心类型,并定义编辑器专用类型。 + */ + +import type { + ModuleCategory, + ModulePlatform, + ModuleManifest, + ModuleExports +} from '@esengine/engine-core'; + +// Re-export core module types +export type { ModuleCategory, ModulePlatform, ModuleManifest, ModuleExports }; + +/** + * Module state in a project. + * 项目中的模块状态。 + */ +export interface ModuleState { + /** Module ID | 模块 ID */ + id: string; + + /** Whether enabled in this project | 在此项目中是否启用 */ + enabled: boolean; + + /** Version being used | 使用的版本 */ + version: string; +} + +/** + * Result of validating a module disable operation. + * 验证禁用模块操作的结果。 + */ +export interface ModuleDisableValidation { + /** Whether the module can be disabled | 是否可以禁用 */ + canDisable: boolean; + + /** Reason why it cannot be disabled | 不能禁用的原因 */ + reason?: 'core' | 'dependency' | 'scene-usage' | 'script-usage'; + + /** Detailed message | 详细消息 */ + message?: string; + + /** Scene files that use this module | 使用此模块的场景文件 */ + sceneUsages?: SceneModuleUsage[]; + + /** Script files that import this module | 导入此模块的脚本文件 */ + scriptUsages?: ScriptModuleUsage[]; + + /** Other modules that depend on this | 依赖此模块的其他模块 */ + dependentModules?: string[]; +} + +/** + * Scene usage of a module. + * 场景对模块的使用。 + */ +export interface SceneModuleUsage { + /** Scene file path | 场景文件路径 */ + scenePath: string; + + /** Components used from the module | 使用的模块组件 */ + components: { + /** Component type name | 组件类型名 */ + type: string; + /** Number of instances | 实例数量 */ + count: number; + }[]; +} + +/** + * Script usage of a module. + * 脚本对模块的使用。 + */ +export interface ScriptModuleUsage { + /** Script file path | 脚本文件路径 */ + scriptPath: string; + + /** Line number of import | 导入的行号 */ + line: number; + + /** Import statement | 导入语句 */ + importStatement: string; +} + +/** + * Project module configuration. + * 项目模块配置。 + */ +export interface ProjectModuleConfig { + /** Enabled module IDs | 启用的模块 ID */ + enabled: string[]; +} + +/** + * Module registry entry with computed properties. + * 带计算属性的模块注册表条目。 + */ +export interface ModuleRegistryEntry extends ModuleManifest { + /** Full path to module directory | 模块目录完整路径 */ + path: string; + + /** Whether module is currently enabled in project | 模块当前是否在项目中启用 */ + isEnabled: boolean; + + /** Modules that depend on this module | 依赖此模块的模块 */ + dependents: string[]; + + /** Whether all dependencies are satisfied | 是否满足所有依赖 */ + dependenciesSatisfied: boolean; +} diff --git a/packages/editor-core/src/Services/Module/index.ts b/packages/editor-core/src/Services/Module/index.ts new file mode 100644 index 00000000..8b17491f --- /dev/null +++ b/packages/editor-core/src/Services/Module/index.ts @@ -0,0 +1,7 @@ +/** + * Module System exports. + * 模块系统导出。 + */ + +export * from './ModuleTypes'; +export * from './ModuleRegistry'; diff --git a/packages/editor-core/src/Services/PreviewSceneService.ts b/packages/editor-core/src/Services/PreviewSceneService.ts new file mode 100644 index 00000000..12d606db --- /dev/null +++ b/packages/editor-core/src/Services/PreviewSceneService.ts @@ -0,0 +1,272 @@ +/** + * Preview Scene Service + * 预览场景服务 + * + * Manages isolated preview scenes for editor tools (tilemap editor, material preview, etc.) + * 管理编辑器工具的隔离预览场景(瓦片地图编辑器、材质预览等) + */ + +import { Scene, EntitySystem, Entity } from '@esengine/ecs-framework'; + +/** + * Configuration for creating a preview scene + * 创建预览场景的配置 + */ +export interface PreviewSceneConfig { + /** Unique identifier for the preview scene | 预览场景的唯一标识符 */ + id: string; + /** Scene name | 场景名称 */ + name?: string; + /** Systems to add to the scene | 要添加到场景的系统 */ + systems?: EntitySystem[]; + /** Initial clear color | 初始清除颜色 */ + clearColor?: { r: number; g: number; b: number; a: number }; +} + +/** + * Represents an isolated preview scene for editor tools + * 表示编辑器工具的隔离预览场景 + */ +export interface IPreviewScene { + /** Scene instance | 场景实例 */ + readonly scene: Scene; + /** Unique identifier | 唯一标识符 */ + readonly id: string; + /** Scene name | 场景名称 */ + readonly name: string; + /** Clear color | 清除颜色 */ + clearColor: { r: number; g: number; b: number; a: number }; + + /** + * Create a temporary entity (auto-cleaned on dispose) + * 创建临时实体(dispose 时自动清理) + */ + createEntity(name: string): Entity; + + /** + * Remove a temporary entity + * 移除临时实体 + */ + removeEntity(entity: Entity): void; + + /** + * Get all entities in the scene + * 获取场景中的所有实体 + */ + getEntities(): readonly Entity[]; + + /** + * Clear all temporary entities + * 清除所有临时实体 + */ + clearEntities(): void; + + /** + * Add a system to the scene + * 向场景添加系统 + */ + addSystem(system: EntitySystem): void; + + /** + * Remove a system from the scene + * 从场景移除系统 + */ + removeSystem(system: EntitySystem): void; + + /** + * Update the scene (process systems) + * 更新场景(处理系统) + */ + update(deltaTime: number): void; + + /** + * Dispose the preview scene + * 释放预览场景 + */ + dispose(): void; +} + +/** + * Preview scene implementation + * 预览场景实现 + */ +class PreviewScene implements IPreviewScene { + readonly scene: Scene; + readonly id: string; + readonly name: string; + clearColor: { r: number; g: number; b: number; a: number }; + + private _entities: Set = new Set(); + private _disposed = false; + + constructor(config: PreviewSceneConfig) { + this.id = config.id; + this.name = config.name ?? `PreviewScene_${config.id}`; + this.clearColor = config.clearColor ?? { r: 0.1, g: 0.1, b: 0.12, a: 1.0 }; + + // Create isolated scene + this.scene = new Scene({ name: this.name }); + + // Add configured systems + if (config.systems) { + for (const system of config.systems) { + this.scene.addSystem(system); + } + } + } + + createEntity(name: string): Entity { + if (this._disposed) { + throw new Error(`PreviewScene ${this.id} is disposed`); + } + + const entity = this.scene.createEntity(name); + this._entities.add(entity); + return entity; + } + + removeEntity(entity: Entity): void { + if (this._disposed) return; + + if (this._entities.has(entity)) { + this._entities.delete(entity); + this.scene.destroyEntities([entity]); + } + } + + getEntities(): readonly Entity[] { + return Array.from(this._entities); + } + + clearEntities(): void { + if (this._disposed) return; + + const entities = Array.from(this._entities); + if (entities.length > 0) { + this.scene.destroyEntities(entities); + } + this._entities.clear(); + } + + addSystem(system: EntitySystem): void { + if (this._disposed) return; + this.scene.addSystem(system); + } + + removeSystem(system: EntitySystem): void { + if (this._disposed) return; + this.scene.removeSystem(system); + } + + update(_deltaTime: number): void { + if (this._disposed) return; + this.scene.update(); + } + + dispose(): void { + if (this._disposed) return; + this._disposed = true; + + // Clear all entities + this.clearEntities(); + + // Scene cleanup is handled by GC + } +} + +/** + * Preview Scene Service - manages all preview scenes + * 预览场景服务 - 管理所有预览场景 + */ +export class PreviewSceneService { + private static _instance: PreviewSceneService | null = null; + private _scenes: Map = new Map(); + + private constructor() {} + + /** + * Get singleton instance + * 获取单例实例 + */ + static getInstance(): PreviewSceneService { + if (!PreviewSceneService._instance) { + PreviewSceneService._instance = new PreviewSceneService(); + } + return PreviewSceneService._instance; + } + + /** + * Create a new preview scene + * 创建新的预览场景 + */ + createScene(config: PreviewSceneConfig): IPreviewScene { + if (this._scenes.has(config.id)) { + throw new Error(`Preview scene with id "${config.id}" already exists`); + } + + const scene = new PreviewScene(config); + this._scenes.set(config.id, scene); + return scene; + } + + /** + * Get a preview scene by ID + * 通过 ID 获取预览场景 + */ + getScene(id: string): IPreviewScene | null { + return this._scenes.get(id) ?? null; + } + + /** + * Check if a preview scene exists + * 检查预览场景是否存在 + */ + hasScene(id: string): boolean { + return this._scenes.has(id); + } + + /** + * Dispose a preview scene + * 释放预览场景 + */ + disposeScene(id: string): void { + const scene = this._scenes.get(id); + if (scene) { + scene.dispose(); + this._scenes.delete(id); + } + } + + /** + * Get all preview scene IDs + * 获取所有预览场景 ID + */ + getSceneIds(): string[] { + return Array.from(this._scenes.keys()); + } + + /** + * Dispose all preview scenes + * 释放所有预览场景 + */ + disposeAll(): void { + for (const scene of this._scenes.values()) { + scene.dispose(); + } + this._scenes.clear(); + } + + /** + * Dispose the service + * 释放服务 + */ + dispose(): void { + this.disposeAll(); + } +} + +/** + * Service identifier for dependency injection + * 依赖注入的服务标识符 + */ +export const IPreviewSceneService = Symbol.for('IPreviewSceneService'); diff --git a/packages/editor-core/src/Services/ProjectService.ts b/packages/editor-core/src/Services/ProjectService.ts index e2b124ff..c264a22e 100644 --- a/packages/editor-core/src/Services/ProjectService.ts +++ b/packages/editor-core/src/Services/ProjectService.ts @@ -35,17 +35,36 @@ export interface PluginSettings { enabledPlugins: string[]; } +/** + * 模块配置 + * Module Configuration + */ +export interface ModuleSettings { + /** + * 禁用的模块 ID 列表(黑名单方式) + * Disabled module IDs (blacklist approach) + * Modules NOT in this list are enabled. + * 不在此列表中的模块为启用状态。 + */ + disabledModules: string[]; +} + export interface ProjectConfig { projectType?: ProjectType; - componentsPath?: string; - componentPattern?: string; + /** User scripts directory (default: 'scripts') | 用户脚本目录(默认:'scripts') */ + scriptsPath?: string; + /** Build output directory | 构建输出目录 */ buildOutput?: string; + /** Scenes directory | 场景目录 */ scenesPath?: string; + /** Default scene file | 默认场景文件 */ defaultScene?: string; - /** UI 设计分辨率 / UI design resolution */ + /** UI design resolution | UI 设计分辨率 */ uiDesignResolution?: UIDesignResolution; - /** 插件配置 / Plugin settings */ + /** Plugin settings | 插件配置 */ plugins?: PluginSettings; + /** Module settings | 模块配置 */ + modules?: ModuleSettings; } @Injectable() @@ -72,9 +91,8 @@ export class ProjectService implements IService { const config: ProjectConfig = { projectType: 'esengine', - componentsPath: 'components', - componentPattern: '**/*.ts', - buildOutput: 'temp/editor-components', + scriptsPath: 'scripts', + buildOutput: '.esengine/compiled', scenesPath: 'scenes', defaultScene: 'main.ecs' }; @@ -153,15 +171,34 @@ export class ProjectService implements IService { return this.currentProject !== null; } - public getComponentsPath(): string | null { + /** + * Get user scripts directory path. + * 获取用户脚本目录路径。 + * + * @returns Scripts directory path | 脚本目录路径 + */ + public getScriptsPath(): string | null { if (!this.currentProject) { return null; } - if (!this.projectConfig?.componentsPath) { - return this.currentProject.path; - } + const scriptsPath = this.projectConfig?.scriptsPath || 'scripts'; const sep = this.currentProject.path.includes('\\') ? '\\' : '/'; - return `${this.currentProject.path}${sep}${this.projectConfig.componentsPath}`; + return `${this.currentProject.path}${sep}${scriptsPath}`; + } + + /** + * Get editor scripts directory path (scripts/editor). + * 获取编辑器脚本目录路径(scripts/editor)。 + * + * @returns Editor scripts directory path | 编辑器脚本目录路径 + */ + public getEditorScriptsPath(): string | null { + const scriptsPath = this.getScriptsPath(); + if (!scriptsPath) { + return null; + } + const sep = scriptsPath.includes('\\') ? '\\' : '/'; + return `${scriptsPath}${sep}editor`; } public getScenesPath(): string | null { @@ -214,15 +251,15 @@ export class ProjectService implements IService { logger.debug('Raw config content:', content); const config = JSON.parse(content) as ProjectConfig; logger.debug('Parsed config plugins:', config.plugins); - const result = { + const result: ProjectConfig = { projectType: config.projectType || 'esengine', - componentsPath: config.componentsPath || '', - componentPattern: config.componentPattern || '**/*.ts', - buildOutput: config.buildOutput || 'temp/editor-components', + scriptsPath: config.scriptsPath || 'scripts', + buildOutput: config.buildOutput || '.esengine/compiled', scenesPath: config.scenesPath || 'scenes', defaultScene: config.defaultScene || 'main.ecs', uiDesignResolution: config.uiDesignResolution, - plugins: config.plugins + plugins: config.plugins, + modules: config.modules }; logger.debug('Loaded config result:', result); return result; @@ -230,9 +267,8 @@ export class ProjectService implements IService { logger.warn('Failed to load config, using defaults', error); return { projectType: 'esengine', - componentsPath: '', - componentPattern: '**/*.ts', - buildOutput: 'temp/editor-components', + scriptsPath: 'scripts', + buildOutput: '.esengine/compiled', scenesPath: 'scenes', defaultScene: 'main.ecs' }; @@ -350,6 +386,70 @@ export class ProjectService implements IService { await this.setEnabledPlugins(current.filter(id => id !== pluginId)); } + // ==================== Module Settings ==================== + + /** + * 获取禁用的模块列表(黑名单) + * Get disabled modules list (blacklist) + * @returns Array of disabled module IDs + */ + public getDisabledModules(): string[] { + return this.projectConfig?.modules?.disabledModules || []; + } + + /** + * 获取模块配置 + * Get module settings + */ + public getModuleSettings(): ModuleSettings | null { + return this.projectConfig?.modules || null; + } + + /** + * 设置禁用的模块列表 + * Set disabled modules list + * + * @param disabledModules - Array of disabled module IDs + */ + public async setDisabledModules(disabledModules: string[]): Promise { + await this.updateConfig({ + modules: { + disabledModules + } + }); + await this.messageHub.publish('project:modulesChanged', { disabledModules }); + logger.info('Module settings saved', { disabledCount: disabledModules.length }); + } + + /** + * 禁用模块 + * Disable a module + */ + public async disableModule(moduleId: string): Promise { + const current = this.getDisabledModules(); + if (!current.includes(moduleId)) { + await this.setDisabledModules([...current, moduleId]); + } + } + + /** + * 启用模块 + * Enable a module + */ + public async enableModule(moduleId: string): Promise { + const current = this.getDisabledModules(); + await this.setDisabledModules(current.filter(id => id !== moduleId)); + } + + /** + * 检查模块是否启用 + * Check if a module is enabled + */ + public isModuleEnabled(moduleId: string): boolean { + const disabled = this.getDisabledModules(); + return !disabled.includes(moduleId); + } + public dispose(): void { this.currentProject = null; this.projectConfig = null; diff --git a/packages/editor-core/src/Services/SettingsRegistry.ts b/packages/editor-core/src/Services/SettingsRegistry.ts index 9a5b1fef..e731af33 100644 --- a/packages/editor-core/src/Services/SettingsRegistry.ts +++ b/packages/editor-core/src/Services/SettingsRegistry.ts @@ -1,6 +1,6 @@ import { Injectable, IService } from '@esengine/ecs-framework'; -export type SettingType = 'string' | 'number' | 'boolean' | 'select' | 'color' | 'range' | 'pluginList' | 'collisionMatrix'; +export type SettingType = 'string' | 'number' | 'boolean' | 'select' | 'color' | 'range' | 'pluginList' | 'collisionMatrix' | 'moduleList'; export interface SettingOption { label: string; diff --git a/packages/editor-core/src/Services/UserCode/IUserCodeService.ts b/packages/editor-core/src/Services/UserCode/IUserCodeService.ts new file mode 100644 index 00000000..0fe90d86 --- /dev/null +++ b/packages/editor-core/src/Services/UserCode/IUserCodeService.ts @@ -0,0 +1,273 @@ +/** + * User Code Service Interface. + * 用户代码服务接口。 + * + * Provides compilation and loading for user-written game logic code. + * 提供用户编写的游戏逻辑代码的编译和加载功能。 + * + * Directory convention: + * 目录约定: + * - scripts/ -> Runtime code (components, systems, etc.) + * - scripts/editor/ -> Editor-only code (inspectors, gizmos, panels) + */ + +/** + * User code target environment. + * 用户代码目标环境。 + */ +export enum UserCodeTarget { + /** Runtime code - runs in game | 运行时代码 - 在游戏中运行 */ + Runtime = 'runtime', + /** Editor code - runs only in editor | 编辑器代码 - 仅在编辑器中运行 */ + Editor = 'editor' +} + +/** + * User script file information. + * 用户脚本文件信息。 + */ +export interface UserScriptInfo { + /** Absolute file path | 文件绝对路径 */ + path: string; + /** Relative path from scripts directory | 相对于 scripts 目录的路径 */ + relativePath: string; + /** Target environment | 目标环境 */ + target: UserCodeTarget; + /** Exported names (classes, functions) | 导出的名称(类、函数) */ + exports: string[]; + /** Last modified timestamp | 最后修改时间戳 */ + lastModified: number; +} + +/** + * User code compilation options. + * 用户代码编译选项。 + */ +export interface UserCodeCompileOptions { + /** Project root directory | 项目根目录 */ + projectPath: string; + /** Target environment | 目标环境 */ + target: UserCodeTarget; + /** Output directory | 输出目录 */ + outputDir?: string; + /** Whether to generate source maps | 是否生成 source map */ + sourceMap?: boolean; + /** Whether to minify output | 是否压缩输出 */ + minify?: boolean; + /** Output format | 输出格式 */ + format?: 'esm' | 'iife'; +} + +/** + * User code compilation result. + * 用户代码编译结果。 + */ +export interface UserCodeCompileResult { + /** Whether compilation succeeded | 是否编译成功 */ + success: boolean; + /** Output file path | 输出文件路径 */ + outputPath?: string; + /** Compilation errors | 编译错误 */ + errors: CompileError[]; + /** Compilation warnings | 编译警告 */ + warnings: CompileError[]; + /** Compilation duration in ms | 编译耗时(毫秒) */ + duration: number; +} + +/** + * Compilation error/warning. + * 编译错误/警告。 + */ +export interface CompileError { + /** Error message | 错误信息 */ + message: string; + /** Source file path | 源文件路径 */ + file?: string; + /** Line number | 行号 */ + line?: number; + /** Column number | 列号 */ + column?: number; +} + +/** + * Loaded user code module. + * 加载后的用户代码模块。 + */ +export interface UserCodeModule { + /** Module ID | 模块 ID */ + id: string; + /** Target environment | 目标环境 */ + target: UserCodeTarget; + /** All exported members | 所有导出的成员 */ + exports: Record; + /** Module version (hash of source) | 模块版本(源码哈希) */ + version: string; + /** Load timestamp | 加载时间戳 */ + loadedAt: number; +} + +/** + * Hot reload event. + * 热更新事件。 + */ +export interface HotReloadEvent { + /** Target environment | 目标环境 */ + target: UserCodeTarget; + /** Changed source files | 变更的源文件 */ + changedFiles: string[]; + /** Previous module (if any) | 之前的模块(如果有) */ + previousModule?: UserCodeModule; + /** New module | 新模块 */ + newModule: UserCodeModule; +} + +/** + * User Code Service interface. + * 用户代码服务接口。 + * + * Handles scanning, compilation, loading, and hot-reload of user scripts. + * 处理用户脚本的扫描、编译、加载和热更新。 + * + * @example + * ```typescript + * const userCodeService = services.resolve(UserCodeService); + * + * // Scan for user scripts | 扫描用户脚本 + * const scripts = await userCodeService.scan(projectPath); + * + * // Compile runtime code | 编译运行时代码 + * const result = await userCodeService.compile({ + * projectPath, + * target: UserCodeTarget.Runtime + * }); + * + * // Load compiled module | 加载编译后的模块 + * if (result.success && result.outputPath) { + * const module = await userCodeService.load(result.outputPath, UserCodeTarget.Runtime); + * userCodeService.registerComponents(module); + * } + * + * // Start hot reload | 启动热更新 + * await userCodeService.watch(projectPath, (event) => { + * console.log('Code reloaded:', event.changedFiles); + * }); + * ``` + */ +export interface IUserCodeService { + /** + * Scan project for user scripts. + * 扫描项目中的用户脚本。 + * + * Looks for: + * 查找: + * - scripts/*.ts -> Runtime code | 运行时代码 + * - scripts/editor/*.tsx -> Editor code | 编辑器代码 + * + * @param projectPath - Project root path | 项目根路径 + * @returns Discovered script files | 发现的脚本文件 + */ + scan(projectPath: string): Promise; + + /** + * Compile user scripts. + * 编译用户脚本。 + * + * @param options - Compilation options | 编译选项 + * @returns Compilation result | 编译结果 + */ + compile(options: UserCodeCompileOptions): Promise; + + /** + * Load compiled user code module. + * 加载编译后的用户代码模块。 + * + * @param modulePath - Path to compiled JS file | 编译后的 JS 文件路径 + * @param target - Target environment | 目标环境 + * @returns Loaded module | 加载的模块 + */ + load(modulePath: string, target: UserCodeTarget): Promise; + + /** + * Unload user code module. + * 卸载用户代码模块。 + * + * @param target - Target environment to unload | 要卸载的目标环境 + */ + unload(target: UserCodeTarget): Promise; + + /** + * Get currently loaded module. + * 获取当前加载的模块。 + * + * @param target - Target environment | 目标环境 + * @returns Loaded module or undefined | 加载的模块或 undefined + */ + getModule(target: UserCodeTarget): UserCodeModule | undefined; + + /** + * Register runtime components/systems from user module. + * 从用户模块注册运行时组件/系统。 + * + * Automatically detects and registers: + * 自动检测并注册: + * - Classes extending Component + * - Classes extending System + * + * @param module - User code module | 用户代码模块 + */ + registerComponents(module: UserCodeModule): void; + + /** + * Register editor extensions from user module. + * 从用户模块注册编辑器扩展。 + * + * Automatically detects and registers: + * 自动检测并注册: + * - Component inspectors + * - Gizmo providers + * - Editor panels + * + * @param module - User code module | 用户代码模块 + */ + registerEditorExtensions(module: UserCodeModule): void; + + /** + * Start watching for file changes (hot reload). + * 开始监视文件变更(热更新)。 + * + * @param projectPath - Project root path | 项目根路径 + * @param onReload - Callback when code is reloaded | 代码重新加载时的回调 + */ + watch(projectPath: string, onReload: (event: HotReloadEvent) => void): Promise; + + /** + * Stop watching for file changes. + * 停止监视文件变更。 + */ + stopWatch(): Promise; + + /** + * Check if watching is active. + * 检查是否正在监视。 + */ + isWatching(): boolean; +} + +/** + * Default scripts directory name. + * 默认脚本目录名称。 + */ +export const SCRIPTS_DIR = 'scripts'; + +/** + * Editor scripts subdirectory name. + * 编辑器脚本子目录名称。 + */ +export const EDITOR_SCRIPTS_DIR = 'editor'; + +/** + * Default output directory for compiled user code. + * 编译后用户代码的默认输出目录。 + */ +export const USER_CODE_OUTPUT_DIR = '.esengine/compiled'; diff --git a/packages/editor-core/src/Services/UserCode/UserCodeService.ts b/packages/editor-core/src/Services/UserCode/UserCodeService.ts new file mode 100644 index 00000000..7e7d4dff --- /dev/null +++ b/packages/editor-core/src/Services/UserCode/UserCodeService.ts @@ -0,0 +1,799 @@ +/** + * User Code Service Implementation. + * 用户代码服务实现。 + * + * Provides compilation, loading, and hot-reload for user-written scripts. + * 提供用户脚本的编译、加载和热更新功能。 + */ + +import type { IService } from '@esengine/ecs-framework'; +import { Injectable, createLogger } from '@esengine/ecs-framework'; +import type { + IUserCodeService, + UserScriptInfo, + UserCodeCompileOptions, + UserCodeCompileResult, + CompileError, + UserCodeModule, + HotReloadEvent +} from './IUserCodeService'; +import { + UserCodeTarget, + SCRIPTS_DIR, + EDITOR_SCRIPTS_DIR, + USER_CODE_OUTPUT_DIR +} from './IUserCodeService'; +import type { IFileSystem, FileEntry } from '../IFileSystem'; + +const logger = createLogger('UserCodeService'); + +/** + * User Code Service. + * 用户代码服务。 + * + * Handles scanning, compilation, loading, and hot-reload of user scripts. + * 处理用户脚本的扫描、编译、加载和热更新。 + */ +@Injectable() +export class UserCodeService implements IService, IUserCodeService { + private _fileSystem: IFileSystem; + private _runtimeModule: UserCodeModule | undefined; + private _editorModule: UserCodeModule | undefined; + private _watching = false; + private _watchCallbacks: Array<(event: HotReloadEvent) => void> = []; + private _currentProjectPath: string | undefined; + private _eventUnlisten: (() => void) | undefined; + + constructor(fileSystem: IFileSystem) { + this._fileSystem = fileSystem; + } + + /** + * Scan project for user scripts. + * 扫描项目中的用户脚本。 + * + * @param projectPath - Project root path | 项目根路径 + * @returns Discovered script files | 发现的脚本文件 + */ + async scan(projectPath: string): Promise { + const scripts: UserScriptInfo[] = []; + const sep = projectPath.includes('\\') ? '\\' : '/'; + const scriptsDir = `${projectPath}${sep}${SCRIPTS_DIR}`; + + try { + // Check if scripts directory exists | 检查脚本目录是否存在 + const exists = await this._fileSystem.exists(scriptsDir); + if (!exists) { + logger.debug('Scripts directory not found | 脚本目录不存在:', scriptsDir); + return scripts; + } + + // Scan all TypeScript files | 扫描所有 TypeScript 文件 + const files = await this._scanDirectory(scriptsDir, ''); + + for (const file of files) { + const isEditorScript = file.relativePath.startsWith(`${EDITOR_SCRIPTS_DIR}${sep}`) || + file.relativePath.startsWith(`${EDITOR_SCRIPTS_DIR}/`); + + const scriptInfo: UserScriptInfo = { + path: file.absolutePath, + relativePath: file.relativePath, + target: isEditorScript ? UserCodeTarget.Editor : UserCodeTarget.Runtime, + exports: await this._extractExports(file.absolutePath), + lastModified: file.lastModified + }; + + scripts.push(scriptInfo); + } + + logger.info(`Scanned ${scripts.length} scripts | 扫描到 ${scripts.length} 个脚本`, { + runtime: scripts.filter(s => s.target === UserCodeTarget.Runtime).length, + editor: scripts.filter(s => s.target === UserCodeTarget.Editor).length + }); + + return scripts; + } catch (error) { + logger.error('Failed to scan scripts | 扫描脚本失败:', error); + return scripts; + } + } + + /** + * Compile user scripts. + * 编译用户脚本。 + * + * @param options - Compilation options | 编译选项 + * @returns Compilation result | 编译结果 + */ + async compile(options: UserCodeCompileOptions): Promise { + const startTime = Date.now(); + const errors: CompileError[] = []; + const warnings: CompileError[] = []; + + const sep = options.projectPath.includes('\\') ? '\\' : '/'; + const scriptsDir = `${options.projectPath}${sep}${SCRIPTS_DIR}`; + const outputDir = options.outputDir || `${options.projectPath}${sep}${USER_CODE_OUTPUT_DIR}`; + + try { + // Scan scripts first | 先扫描脚本 + const allScripts = await this.scan(options.projectPath); + const targetScripts = allScripts.filter(s => s.target === options.target); + + if (targetScripts.length === 0) { + logger.info(`No ${options.target} scripts to compile | 没有需要编译的 ${options.target} 脚本`); + return { + success: true, + outputPath: undefined, + errors: [], + warnings: [], + duration: Date.now() - startTime + }; + } + + // Ensure output directory exists | 确保输出目录存在 + await this._ensureDirectory(outputDir); + + // Determine output file name | 确定输出文件名 + const outputFileName = options.target === UserCodeTarget.Runtime + ? 'user-runtime.js' + : 'user-editor.js'; + const outputPath = `${outputDir}${sep}${outputFileName}`; + + // Build entry point content | 构建入口点内容 + const entryContent = this._buildEntryPoint(targetScripts, scriptsDir, options.target); + + // Create temporary entry file | 创建临时入口文件 + const entryPath = `${outputDir}${sep}_entry_${options.target}.ts`; + await this._fileSystem.writeFile(entryPath, entryContent); + + // Compile using esbuild (via Tauri command or direct) | 使用 esbuild 编译 + const compileResult = await this._runEsbuild({ + entryPath, + outputPath, + format: options.format || 'esm', + sourceMap: options.sourceMap ?? true, + minify: options.minify ?? false, + external: this._getExternalDependencies(options.target), + projectRoot: options.projectPath + }); + + if (!compileResult.success) { + errors.push(...compileResult.errors); + } + + // Clean up temporary entry file | 清理临时入口文件 + try { + await this._fileSystem.deleteFile(entryPath); + } catch { + // Ignore cleanup errors | 忽略清理错误 + } + + const duration = Date.now() - startTime; + logger.info(`Compilation ${compileResult.success ? 'succeeded' : 'failed'} | 编译${compileResult.success ? '成功' : '失败'}`, { + target: options.target, + duration: `${duration}ms`, + files: targetScripts.length + }); + + return { + success: compileResult.success, + outputPath: compileResult.success ? outputPath : undefined, + errors, + warnings, + duration + }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + errors.push({ message: errorMessage }); + + logger.error('Compilation failed | 编译失败:', error); + + return { + success: false, + errors, + warnings, + duration: Date.now() - startTime + }; + } + } + + /** + * Load compiled user code module. + * 加载编译后的用户代码模块。 + * + * @param modulePath - Path to compiled JS file | 编译后的 JS 文件路径 + * @param target - Target environment | 目标环境 + * @returns Loaded module | 加载的模块 + */ + async load(modulePath: string, target: UserCodeTarget): Promise { + try { + // Add cache-busting query parameter for hot reload | 添加缓存破坏参数用于热更新 + const cacheBuster = `?t=${Date.now()}`; + const moduleUrl = `file://${modulePath}${cacheBuster}`; + + // Dynamic import the module | 动态导入模块 + const moduleExports = await import(/* @vite-ignore */ moduleUrl); + + const module: UserCodeModule = { + id: `user-${target}-${Date.now()}`, + target, + exports: { ...moduleExports }, + version: String(Date.now()), + loadedAt: Date.now() + }; + + // Store reference | 存储引用 + if (target === UserCodeTarget.Runtime) { + this._runtimeModule = module; + } else { + this._editorModule = module; + } + + logger.info(`Module loaded | 模块已加载`, { + target, + exports: Object.keys(module.exports).length + }); + + return module; + } catch (error) { + logger.error('Failed to load module | 加载模块失败:', error); + throw error; + } + } + + /** + * Unload user code module. + * 卸载用户代码模块。 + * + * @param target - Target environment to unload | 要卸载的目标环境 + */ + async unload(target: UserCodeTarget): Promise { + if (target === UserCodeTarget.Runtime) { + this._runtimeModule = undefined; + } else { + this._editorModule = undefined; + } + logger.info(`Module unloaded | 模块已卸载`, { target }); + } + + /** + * Get currently loaded module. + * 获取当前加载的模块。 + * + * @param target - Target environment | 目标环境 + * @returns Loaded module or undefined | 加载的模块或 undefined + */ + getModule(target: UserCodeTarget): UserCodeModule | undefined { + return target === UserCodeTarget.Runtime ? this._runtimeModule : this._editorModule; + } + + /** + * Register runtime components/systems from user module. + * 从用户模块注册运行时组件/系统。 + * + * @param module - User code module | 用户代码模块 + */ + registerComponents(module: UserCodeModule): void { + if (module.target !== UserCodeTarget.Runtime) { + logger.warn('Cannot register components from editor module | 无法从编辑器模块注册组件'); + return; + } + + let componentCount = 0; + let systemCount = 0; + + for (const [name, exported] of Object.entries(module.exports)) { + if (typeof exported !== 'function') { + continue; + } + + // 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}`); + componentCount++; + } + + // Check if it's a System subclass | 检查是否是 System 子类 + if (this._isSystemClass(exported)) { + logger.debug(`Found system: ${name} | 发现系统: ${name}`); + systemCount++; + } + } + + logger.info(`Registered user code | 注册用户代码`, { + components: componentCount, + systems: systemCount + }); + } + + /** + * Register editor extensions from user module. + * 从用户模块注册编辑器扩展。 + * + * @param module - User code module | 用户代码模块 + */ + registerEditorExtensions(module: UserCodeModule): void { + if (module.target !== UserCodeTarget.Editor) { + logger.warn('Cannot register editor extensions from runtime module | 无法从运行时模块注册编辑器扩展'); + return; + } + + let inspectorCount = 0; + let gizmoCount = 0; + let panelCount = 0; + + for (const [name, exported] of Object.entries(module.exports)) { + if (typeof exported !== 'function') { + continue; + } + + // Check for inspector | 检查检查器 + if (this._isInspectorClass(exported)) { + logger.debug(`Found inspector: ${name} | 发现检查器: ${name}`); + inspectorCount++; + } + + // Check for gizmo | 检查 Gizmo + if (this._isGizmoClass(exported)) { + logger.debug(`Found gizmo: ${name} | 发现 Gizmo: ${name}`); + gizmoCount++; + } + + // Check for panel | 检查面板 + if (this._isPanelComponent(exported)) { + logger.debug(`Found panel: ${name} | 发现面板: ${name}`); + panelCount++; + } + } + + logger.info(`Registered editor extensions | 注册编辑器扩展`, { + inspectors: inspectorCount, + gizmos: gizmoCount, + panels: panelCount + }); + } + + /** + * Start watching for file changes (hot reload). + * 开始监视文件变更(热更新)。 + * + * @param projectPath - Project root path | 项目根路径 + * @param onReload - Callback when code is reloaded | 代码重新加载时的回调 + */ + async watch(projectPath: string, onReload: (event: HotReloadEvent) => void): Promise { + if (this._watching) { + this._watchCallbacks.push(onReload); + return; + } + + this._currentProjectPath = projectPath; + + try { + // Check if we're in Tauri environment | 检查是否在 Tauri 环境 + if (typeof window !== 'undefined' && '__TAURI__' in window) { + const { invoke } = await import('@tauri-apps/api/core'); + const { listen } = await import('@tauri-apps/api/event'); + + // Start backend file watcher | 启动后端文件监视器 + await invoke('watch_scripts', { + projectPath, + scriptsDir: SCRIPTS_DIR + }); + + // Listen for file change events | 监听文件变更事件 + this._eventUnlisten = await listen<{ + changeType: string; + paths: string[]; + }>('user-code:file-changed', async (event) => { + const { changeType, paths } = event.payload; + + logger.info('File change detected | 检测到文件变更', { changeType, paths }); + + // Determine which targets are affected | 确定受影响的目标 + const isEditorChange = paths.some(p => + p.includes(`${EDITOR_SCRIPTS_DIR}/`) || p.includes(`${EDITOR_SCRIPTS_DIR}\\`) + ); + const target = isEditorChange ? UserCodeTarget.Editor : UserCodeTarget.Runtime; + + // Get previous module | 获取之前的模块 + const previousModule = this.getModule(target); + + // Recompile the affected target | 重新编译受影响的目标 + const compileResult = await this.compile({ + projectPath, + target + }); + + if (compileResult.success && compileResult.outputPath) { + // Reload the module | 重新加载模块 + const newModule = await this.load(compileResult.outputPath, target); + + // Create hot reload event | 创建热更新事件 + const hotReloadEvent: HotReloadEvent = { + target, + changedFiles: paths, + previousModule, + newModule + }; + + this._notifyHotReload(hotReloadEvent); + } else { + logger.error('Hot reload compilation failed | 热更新编译失败', compileResult.errors); + } + }); + + this._watching = true; + this._watchCallbacks.push(onReload); + + logger.info('Started watching for changes | 开始监视文件变更', { + path: `${projectPath}/${SCRIPTS_DIR}` + }); + } else { + // Not in Tauri - just register callback for manual triggers + // 不在 Tauri 环境 - 只注册回调用于手动触发 + logger.warn('File watching not available outside Tauri | 文件监视在 Tauri 外不可用'); + this._watching = true; + this._watchCallbacks.push(onReload); + } + } catch (error) { + logger.error('Failed to start watching | 启动监视失败:', error); + throw error; + } + } + + /** + * Stop watching for file changes. + * 停止监视文件变更。 + */ + async stopWatch(): Promise { + if (!this._watching) { + return; + } + + try { + // Unsubscribe from Tauri events | 取消订阅 Tauri 事件 + if (this._eventUnlisten) { + this._eventUnlisten(); + this._eventUnlisten = undefined; + } + + // Stop backend file watcher | 停止后端文件监视器 + if (typeof window !== 'undefined' && '__TAURI__' in window) { + const { invoke } = await import('@tauri-apps/api/core'); + await invoke('stop_watch_scripts', { + projectPath: this._currentProjectPath + }); + } + } catch (error) { + logger.warn('Error stopping file watcher | 停止文件监视器时出错:', error); + } + + this._watching = false; + this._watchCallbacks = []; + this._currentProjectPath = undefined; + + logger.info('Stopped watching for changes | 停止监视文件变更'); + } + + /** + * Check if watching is active. + * 检查是否正在监视。 + */ + isWatching(): boolean { + return this._watching; + } + + /** + * Dispose service resources. + * 释放服务资源。 + */ + dispose(): void { + this.stopWatch(); + this._runtimeModule = undefined; + this._editorModule = undefined; + } + + // ==================== Private Methods | 私有方法 ==================== + + /** + * Scan directory recursively for TypeScript files. + * 递归扫描目录中的 TypeScript 文件。 + */ + private async _scanDirectory( + baseDir: string, + relativePath: string + ): Promise> { + const results: Array<{ absolutePath: string; relativePath: string; lastModified: number }> = []; + const sep = baseDir.includes('\\') ? '\\' : '/'; + const currentDir = relativePath ? `${baseDir}${sep}${relativePath}` : baseDir; + + try { + const entries: FileEntry[] = await this._fileSystem.listDirectory(currentDir); + + for (const entry of entries) { + const entryRelativePath = relativePath ? `${relativePath}${sep}${entry.name}` : entry.name; + const entryAbsolutePath = `${baseDir}${sep}${entryRelativePath}`; + + if (entry.isDirectory) { + // Recursively scan subdirectories | 递归扫描子目录 + const subResults = await this._scanDirectory(baseDir, entryRelativePath); + results.push(...subResults); + } else if (entry.name.endsWith('.ts') || entry.name.endsWith('.tsx')) { + results.push({ + absolutePath: entryAbsolutePath, + relativePath: entryRelativePath, + lastModified: entry.modified?.getTime() || Date.now() + }); + } + } + } catch (error) { + logger.warn(`Failed to scan directory | 扫描目录失败: ${currentDir}`, error); + } + + return results; + } + + /** + * Extract exported names from a TypeScript file. + * 从 TypeScript 文件中提取导出的名称。 + */ + private async _extractExports(filePath: string): Promise { + try { + const content = await this._fileSystem.readFile(filePath); + const exports: string[] = []; + + // Simple regex-based extraction | 简单的正则表达式提取 + // Match: export class ClassName, export function funcName, export const varName + const exportClassRegex = /export\s+class\s+(\w+)/g; + const exportFunctionRegex = /export\s+function\s+(\w+)/g; + const exportConstRegex = /export\s+const\s+(\w+)/g; + const exportDefaultRegex = /export\s+default\s+(?:class|function)?\s*(\w+)?/g; + + let match; + while ((match = exportClassRegex.exec(content)) !== null) { + exports.push(match[1]); + } + while ((match = exportFunctionRegex.exec(content)) !== null) { + exports.push(match[1]); + } + while ((match = exportConstRegex.exec(content)) !== null) { + exports.push(match[1]); + } + while ((match = exportDefaultRegex.exec(content)) !== null) { + if (match[1]) { + exports.push(match[1]); + } + } + + return exports; + } catch { + return []; + } + } + + /** + * Build entry point content that re-exports all user scripts. + * 构建重新导出所有用户脚本的入口点内容。 + */ + private _buildEntryPoint( + scripts: UserScriptInfo[], + scriptsDir: string, + target: UserCodeTarget + ): string { + const lines: string[] = [ + '// Auto-generated entry point for user scripts', + '// 自动生成的用户脚本入口点', + '' + ]; + + 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}';`); + } else { + // Re-export everything if we couldn't detect specific exports + // 如果无法检测到具体导出,则重新导出所有内容 + lines.push(`export * from './${SCRIPTS_DIR}/${relativePath}';`); + } + } + + return lines.join('\n'); + } + + /** + * Get external dependencies that should not be bundled. + * 获取不应打包的外部依赖。 + */ + private _getExternalDependencies(target: UserCodeTarget): string[] { + const common = [ + '@esengine/ecs-framework', + '@esengine/engine-core', + '@esengine/core', + '@esengine/math' + ]; + + if (target === UserCodeTarget.Editor) { + return [ + ...common, + '@esengine/editor-core', + 'react', + 'react-dom' + ]; + } + + return common; + } + + /** + * Run esbuild to compile TypeScript. + * 运行 esbuild 编译 TypeScript。 + * + * Uses Tauri command to invoke esbuild CLI. + * 使用 Tauri 命令调用 esbuild CLI。 + */ + private async _runEsbuild(options: { + entryPath: string; + outputPath: string; + format: 'esm' | 'iife'; + sourceMap: boolean; + minify: boolean; + external: string[]; + projectRoot: string; + }): Promise<{ success: boolean; errors: CompileError[] }> { + try { + // Check if we're in Tauri environment | 检查是否在 Tauri 环境 + if (typeof window !== 'undefined' && '__TAURI__' in window) { + // Use Tauri command | 使用 Tauri 命令 + const { invoke } = await import('@tauri-apps/api/core'); + + const result = await invoke<{ + success: boolean; + errors: Array<{ + message: string; + file?: string; + line?: number; + column?: number; + }>; + outputPath?: string; + }>('compile_typescript', { + options: { + entryPath: options.entryPath, + outputPath: options.outputPath, + format: options.format, + sourceMap: options.sourceMap, + minify: options.minify, + external: options.external, + projectRoot: options.projectRoot + } + }); + + return { + success: result.success, + errors: result.errors.map(e => ({ + message: e.message, + file: e.file, + line: e.line, + column: e.column + })) + }; + } else { + // Not in Tauri environment, return mock success for development + // 不在 Tauri 环境,返回模拟成功用于开发 + logger.warn('Not in Tauri environment, skipping compilation | 不在 Tauri 环境,跳过编译'); + return { + success: true, + errors: [] + }; + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + logger.error('esbuild compilation failed | esbuild 编译失败:', error); + + return { + success: false, + errors: [{ message: errorMessage }] + }; + } + } + + /** + * Ensure directory exists, create if not. + * 确保目录存在,如果不存在则创建。 + */ + private async _ensureDirectory(dirPath: string): Promise { + try { + const exists = await this._fileSystem.exists(dirPath); + if (!exists) { + await this._fileSystem.createDirectory(dirPath); + } + } catch (error) { + logger.warn('Failed to ensure directory | 确保目录失败:', dirPath, error); + } + } + + /** + * Check if a class extends Component. + * 检查类是否继承自 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); + } + return false; + } + + /** + * Check if a class extends System. + * 检查类是否继承自 System。 + */ + private _isSystemClass(cls: any): boolean { + let proto = cls.prototype; + while (proto) { + const name = proto.constructor?.name; + if (name === 'System' || name === 'EntityProcessingSystem') { + return true; + } + proto = Object.getPrototypeOf(proto); + } + return false; + } + + /** + * Check if a class implements IComponentInspector. + * 检查类是否实现了 IComponentInspector。 + */ + private _isInspectorClass(cls: any): boolean { + // Check for inspector interface markers | 检查检查器接口标记 + const instance = cls.prototype; + return instance && + typeof instance.canHandle === 'function' && + typeof instance.render === 'function' && + 'targetComponents' in instance; + } + + /** + * Check if a class implements IGizmoProvider. + * 检查类是否实现了 IGizmoProvider。 + */ + private _isGizmoClass(cls: any): boolean { + const instance = cls.prototype; + return instance && + typeof instance.draw === 'function' && + 'targetComponent' in instance; + } + + /** + * Check if a value is a React panel component. + * 检查值是否是 React 面板组件。 + */ + private _isPanelComponent(cls: any): boolean { + // Check for panel descriptor | 检查面板描述符 + return cls.panelDescriptor !== undefined || + cls.displayName?.includes('Panel') || + cls.name?.includes('Panel'); + } + + /** + * Notify callbacks of hot reload event. + * 通知回调热更新事件。 + */ + private _notifyHotReload(event: HotReloadEvent): void { + for (const callback of this._watchCallbacks) { + try { + callback(event); + } catch (error) { + logger.error('Hot reload callback error | 热更新回调错误:', error); + } + } + } +} diff --git a/packages/editor-core/src/Services/UserCode/index.ts b/packages/editor-core/src/Services/UserCode/index.ts new file mode 100644 index 00000000..141149ff --- /dev/null +++ b/packages/editor-core/src/Services/UserCode/index.ts @@ -0,0 +1,115 @@ +/** + * User Code System. + * 用户代码系统。 + * + * Provides compilation, loading, and hot-reload for user-written scripts. + * 提供用户脚本的编译、加载和热更新功能。 + * + * # Directory Convention | 目录约定 + * + * ``` + * my-game/ + * ├── scripts/ # User scripts | 用户脚本 + * │ ├── Player.ts # Runtime code | 运行时代码 + * │ ├── Enemy.ts # Runtime code | 运行时代码 + * │ ├── systems/ # Can organize freely | 可自由组织 + * │ │ └── MovementSystem.ts + * │ └── editor/ # Editor-only code | 仅编辑器代码 + * │ ├── PlayerInspector.tsx + * │ └── EnemyGizmo.tsx + * ├── scenes/ + * ├── assets/ + * └── esengine.config.json + * ``` + * + * # Rules | 规则 + * + * 1. All `.ts` files in `scripts/` (except `scripts/editor/`) are Runtime code + * `scripts/` 下所有 `.ts` 文件(除了 `scripts/editor/`)是运行时代码 + * + * 2. All files in `scripts/editor/` are Editor-only code + * `scripts/editor/` 下所有文件是编辑器专用代码 + * + * 3. Editor code can import Runtime code, but not vice versa + * 编辑器代码可以导入运行时代码,但反过来不行 + * + * 4. Editor code is tree-shaken from production builds + * 编辑器代码会从生产构建中移除 + * + * # Workflow | 工作流程 + * + * ``` + * [User writes .ts files] + * ↓ + * [UserCodeService.scan()] - Discovers all scripts + * ↓ + * [UserCodeService.compile()] - Compiles to JS using esbuild + * ↓ + * [UserCodeService.load()] - Loads compiled module + * ↓ + * [registerComponents()] - Registers with ECS runtime + * [registerEditorExtensions()] - Registers inspectors/gizmos + * ↓ + * [UserCodeService.watch()] - Hot reload on file changes + * ``` + * + * # Example User Component | 用户组件示例 + * + * ```typescript + * // scripts/Player.ts + * import { Component, Serialize, Property } from '@esengine/ecs-framework'; + * + * export class PlayerComponent extends Component { + * @Serialize() + * @Property({ label: 'Speed' }) + * speed: number = 5; + * + * @Serialize() + * @Property({ label: 'Health' }) + * health: number = 100; + * } + * ``` + * + * # Example User Inspector | 用户检查器示例 + * + * ```typescript + * // scripts/editor/PlayerInspector.tsx + * import React from 'react'; + * import { IComponentInspector } from '@esengine/editor-core'; + * import { PlayerComponent } from '../Player'; + * + * export class PlayerInspector implements IComponentInspector { + * id = 'player-inspector'; + * name = 'Player Inspector'; + * targetComponents = ['PlayerComponent']; + * renderMode = 'append' as const; + * + * canHandle(component: any): component is PlayerComponent { + * return component instanceof PlayerComponent; + * } + * + * render(context) { + * return
Custom player UI here
; + * } + * } + * ``` + */ + +export type { + IUserCodeService, + UserScriptInfo, + UserCodeCompileOptions, + UserCodeCompileResult, + CompileError, + UserCodeModule, + HotReloadEvent +} from './IUserCodeService'; + +export { + UserCodeTarget, + SCRIPTS_DIR, + EDITOR_SCRIPTS_DIR, + USER_CODE_OUTPUT_DIR +} from './IUserCodeService'; + +export { UserCodeService } from './UserCodeService'; diff --git a/packages/editor-core/src/index.ts b/packages/editor-core/src/index.ts index 4765d2e0..42cdabb4 100644 --- a/packages/editor-core/src/index.ts +++ b/packages/editor-core/src/index.ts @@ -40,8 +40,21 @@ export * from './Services/FieldEditorRegistry'; export * from './Services/ComponentInspectorRegistry'; export * from './Services/ComponentActionRegistry'; export * from './Services/AssetRegistryService'; +export * from './Services/IViewportService'; +export * from './Services/PreviewSceneService'; +export * from './Services/EditorViewportService'; + +// Build System | 构建系统 +export * from './Services/Build'; + +// User Code System | 用户代码系统 +export * from './Services/UserCode'; + +// Module System | 模块系统 +export * from './Services/Module'; export * from './Gizmos'; +export * from './Rendering'; export * from './Module/IEventBus'; export * from './Module/ICommandRegistry';