feat(editor-core): 添加构建系统和模块管理

This commit is contained in:
yhh
2025-12-03 16:19:40 +08:00
parent d3dfaa7aac
commit 55f644a091
26 changed files with 6089 additions and 163 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,6 @@
/**
* Rendering module exports
* 渲染模块导出
*/
export * from './IViewportOverlay';

View File

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

View File

@@ -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<BuildPlatform, IBuildPipeline>();
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<Array<{
platform: BuildPlatform;
displayName: string;
description?: string;
available: boolean;
reason?: string;
}>> {
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<BuildResult> {
// 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();
}
}
}

View File

@@ -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<void>;
/** 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<string, any>;
}
/**
* 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<BuildResult>;
/**
* 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;
}

View File

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

View File

@@ -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<BuildResult> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<string, unknown> = {
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<void> {
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) | 加载 WASMiOS 上使用 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<void> {
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<void> {
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<void> {
console.log('[WeChatBuild] Optimization complete | 优化完成');
// Minification is done during bundling | 压缩在打包时已完成
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,7 @@
/**
* Build Pipelines.
* 构建管线。
*/
export { WebBuildPipeline, type IBuildFileSystem } from './WebBuildPipeline';
export { WeChatBuildPipeline } from './WeChatBuildPipeline';

View File

@@ -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<T extends Component = Component> {
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;
}
/**
* 获取所有注册的检查器
*/

View File

@@ -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) | 预览场景 IDnull = 主场景) */
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<string, IViewportOverlay>;
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<T extends IViewportOverlay>(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<string, ViewportState> = new Map();
private _previewSceneService = PreviewSceneService.getInstance();
private _viewportDimensions: Map<string, { width: number; height: number }> = 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<T extends IViewportOverlay>(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');

View File

@@ -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<number>;
/**
* Dispose resources (required by IService)
* 释放资源IService 要求)
*/
dispose(): void;
}
/**
* Service identifier for dependency injection
* 依赖注入的服务标识符
*/
export const IViewportService_ID = Symbol.for('IViewportService');

View File

@@ -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<T>(path: string): Promise<T>;
/** Write JSON file | 写入 JSON 文件 */
writeJson(path: string, data: unknown): Promise<void>;
/** Check if path exists | 检查路径是否存在 */
pathExists(path: string): Promise<boolean>;
/** List files by extension | 按扩展名列出文件 */
listFiles(dir: string, extensions: string[], recursive?: boolean): Promise<string[]>;
/** Read file as text | 读取文件为文本 */
readText(path: string): Promise<string>;
}
/**
* Module Registry Service.
* 模块注册表服务。
*/
export class ModuleRegistry {
private _modules: Map<string, ModuleRegistryEntry> = 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<void> {
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<void> {
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<string, ModuleRegistryEntry[]> {
const categories = new Map<string, ModuleRegistryEntry[]>();
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<ModuleDisableValidation> {
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<boolean> {
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<boolean> {
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<void> {
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<ModuleManifest>(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<void> {
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<void> {
if (!this._fileSystem || !this._projectPath) return;
const configPath = `${this._projectPath}/esengine.project.json`;
try {
let config: Record<string, unknown> = {};
if (await this._fileSystem.pathExists(configPath)) {
config = await this._fileSystem.readJson<Record<string, unknown>>(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<SceneModuleUsage[]> {
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<ScriptModuleUsage[]> {
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();

View File

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

View File

@@ -0,0 +1,7 @@
/**
* Module System exports.
* 模块系统导出。
*/
export * from './ModuleTypes';
export * from './ModuleRegistry';

View File

@@ -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<Entity> = 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<string, PreviewScene> = 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');

View File

@@ -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<void> {
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<void> {
const current = this.getDisabledModules();
if (!current.includes(moduleId)) {
await this.setDisabledModules([...current, moduleId]);
}
}
/**
* 启用模块
* Enable a module
*/
public async enableModule(moduleId: string): Promise<void> {
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;

View File

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

View File

@@ -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<string, any>;
/** 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<UserScriptInfo[]>;
/**
* Compile user scripts.
* 编译用户脚本。
*
* @param options - Compilation options | 编译选项
* @returns Compilation result | 编译结果
*/
compile(options: UserCodeCompileOptions): Promise<UserCodeCompileResult>;
/**
* 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<UserCodeModule>;
/**
* Unload user code module.
* 卸载用户代码模块。
*
* @param target - Target environment to unload | 要卸载的目标环境
*/
unload(target: UserCodeTarget): Promise<void>;
/**
* 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<void>;
/**
* Stop watching for file changes.
* 停止监视文件变更。
*/
stopWatch(): Promise<void>;
/**
* 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';

View File

@@ -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<UserScriptInfo[]> {
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<UserCodeCompileResult> {
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<UserCodeModule> {
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<void> {
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<void> {
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<void> {
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<Array<{ absolutePath: string; relativePath: string; lastModified: number }>> {
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<string[]> {
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<void> {
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);
}
}
}
}

View File

@@ -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<PlayerComponent> {
* 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 <div>Custom player UI here</div>;
* }
* }
* ```
*/
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';

View File

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