refactor: reorganize package structure and decouple framework packages (#338)

* refactor: reorganize package structure and decouple framework packages

## Package Structure Reorganization
- Reorganized 55 packages into categorized subdirectories:
  - packages/framework/ - Generic framework (Laya/Cocos compatible)
  - packages/engine/ - ESEngine core modules
  - packages/rendering/ - Rendering modules (WASM dependent)
  - packages/physics/ - Physics modules
  - packages/streaming/ - World streaming
  - packages/network-ext/ - Network extensions
  - packages/editor/ - Editor framework and plugins
  - packages/rust/ - Rust WASM engine
  - packages/tools/ - Build tools and SDK

## Framework Package Decoupling
- Decoupled behavior-tree and blueprint packages from ESEngine dependencies
- Created abstracted interfaces (IBTAssetManager, IBehaviorTreeAssetContent)
- ESEngine-specific code moved to esengine/ subpath exports
- Framework packages now usable with Cocos/Laya without ESEngine

## CI Configuration
- Updated CI to only type-check and lint framework packages
- Added type-check:framework and lint:framework scripts

## Breaking Changes
- Package import paths changed due to directory reorganization
- ESEngine integrations now use subpath imports (e.g., '@esengine/behavior-tree/esengine')

* fix: update es-engine file path after directory reorganization

* docs: update README to focus on framework over engine

* ci: only build framework packages, remove Rust/WASM dependencies

* fix: remove esengine subpath from behavior-tree and blueprint builds

ESEngine integration code will only be available in full engine builds.
Framework packages are now purely engine-agnostic.

* fix: move network-protocols to framework, build both in CI

* fix: update workflow paths from packages/core to packages/framework/core

* fix: exclude esengine folder from type-check in behavior-tree and blueprint

* fix: update network tsconfig references to new paths

* fix: add test:ci:framework to only test framework packages in CI

* fix: only build core and math npm packages in CI

* fix: exclude test files from CodeQL and fix string escaping security issue
This commit is contained in:
YHH
2025-12-26 14:50:35 +08:00
committed by GitHub
parent a84ff902e4
commit 155411e743
1936 changed files with 4147 additions and 11578 deletions

View File

@@ -0,0 +1,375 @@
/**
* 编辑器配置
*
* 集中管理所有编辑器相关的路径、文件名、全局变量等配置。
* 避免硬编码分散在各处,提高可维护性和可配置性。
*
* Editor configuration.
* Centralized management of all editor-related paths, filenames, and global variables.
* Avoids scattered hardcoding, improving maintainability and configurability.
*/
/**
* 路径配置
* Path configuration
*/
export interface IPathConfig {
/** 用户脚本目录 | User scripts directory */
readonly scripts: string;
/** 编辑器脚本子目录 | Editor scripts subdirectory */
readonly editorScripts: string;
/** 编译输出目录 | Compiled output directory */
readonly compiled: string;
/** 插件目录 | Plugins directory */
readonly plugins: string;
/** 资源目录 | Assets directory */
readonly assets: string;
/** 引擎模块目录 | Engine modules directory */
readonly engineModules: string;
}
/**
* 输出文件配置
* Output file configuration
*/
export interface IOutputConfig {
/** 运行时代码包 | Runtime bundle filename */
readonly runtimeBundle: string;
/** 编辑器代码包 | Editor bundle filename */
readonly editorBundle: string;
}
/**
* 全局变量名配置
* Global variable names configuration
*/
export interface IGlobalsConfig {
/** SDK 全局对象名 | SDK global object name */
readonly sdk: string;
/** 插件容器全局对象名 | Plugins container global object name */
readonly plugins: string;
/** 用户运行时导出全局变量名 | User runtime exports global variable name */
readonly userRuntimeExports: string;
/** 用户编辑器导出全局变量名 | User editor exports global variable name */
readonly userEditorExports: string;
}
/**
* 项目配置文件名
* Project configuration filenames
*/
export interface IProjectFilesConfig {
/** 项目配置文件 | Project configuration file */
readonly projectConfig: string;
/** 模块索引文件 | Module index file */
readonly moduleIndex: string;
/** 模块清单文件 | Module manifest file */
readonly moduleManifest: string;
}
/**
* 包名配置
* Package name configuration
*/
export interface IPackageConfig {
/** 包作用域 | Package scope */
readonly scope: string;
/** 核心框架包名 | Core framework package name */
readonly coreFramework: string;
}
/**
* 类型标记配置
* Type marker configuration
*
* 用于标记用户代码相关的运行时属性。
* 注意:组件和系统的类型检测使用 @ECSComponent/@ECSSystem 装饰器的 Symbol 键。
*
* Used for marking user code related runtime properties.
* Note: Component and System type detection uses Symbol keys from @ECSComponent/@ECSSystem decorators.
*/
export interface ITypeMarkersConfig {
/** 用户系统标记 | User system marker */
readonly userSystem: string;
/** 用户系统名称属性 | User system name property */
readonly userSystemName: string;
}
/**
* SDK 模块类型
* SDK module type
*/
export type SDKModuleType = 'core' | 'runtime' | 'editor';
/**
* SDK 模块配置
* SDK module configuration
*
* 定义暴露给插件和用户代码的 SDK 模块。
* Defines SDK modules exposed to plugins and user code.
*/
export interface ISDKModuleConfig {
/**
* 包名
* Package name
* @example '@esengine/ecs-framework'
*/
readonly packageName: string;
/**
* 全局变量键名(已废弃,现使用统一 SDK
* Global variable key name (deprecated, now using unified SDK)
* @deprecated 使用 @esengine/sdk 代替 | Use @esengine/sdk instead
*/
readonly globalKey: string;
/**
* 模块类型
* Module type
* - core: 核心模块,必须加载
* - runtime: 运行时模块,游戏运行时可用
* - editor: 编辑器模块,仅编辑器环境可用
*/
readonly type: SDKModuleType;
/**
* 是否启用(默认 true
* Whether enabled (default true)
*/
readonly enabled?: boolean;
}
/**
* 编辑器配置接口
* Editor configuration interface
*/
export interface IEditorConfig {
readonly paths: IPathConfig;
readonly output: IOutputConfig;
readonly globals: IGlobalsConfig;
readonly projectFiles: IProjectFilesConfig;
readonly package: IPackageConfig;
readonly typeMarkers: ITypeMarkersConfig;
readonly sdkModules: readonly ISDKModuleConfig[];
}
/**
* 默认编辑器配置
* Default editor configuration
*/
export const EditorConfig: IEditorConfig = {
paths: {
scripts: 'scripts',
editorScripts: 'editor',
compiled: '.esengine/compiled',
plugins: 'plugins',
assets: 'assets',
engineModules: 'engine',
},
output: {
runtimeBundle: 'user-runtime.js',
editorBundle: 'user-editor.js',
},
globals: {
sdk: '__ESENGINE_SDK__',
plugins: '__ESENGINE_PLUGINS__',
userRuntimeExports: '__USER_RUNTIME_EXPORTS__',
userEditorExports: '__USER_EDITOR_EXPORTS__',
},
projectFiles: {
projectConfig: 'esengine.project.json',
moduleIndex: 'index.json',
moduleManifest: 'module.json',
},
package: {
scope: '@esengine',
coreFramework: '@esengine/ecs-framework',
},
typeMarkers: {
userSystem: '__isUserSystem__',
userSystemName: '__userSystemName__',
},
sdkModules: [
// 统一 SDK 入口 - 用户代码唯一入口
// Unified SDK entry - the only entry point for user code
{ packageName: '@esengine/sdk', globalKey: 'sdk', type: 'core' },
],
} as const;
/**
* 获取完整的脚本目录路径
* Get full scripts directory path
*
* @param projectPath 项目根路径 | Project root path
*/
export function getScriptsPath(projectPath: string): string {
return `${projectPath}/${EditorConfig.paths.scripts}`;
}
/**
* 获取编辑器脚本目录路径
* Get editor scripts directory path
*
* @param projectPath 项目根路径 | Project root path
*/
export function getEditorScriptsPath(projectPath: string): string {
return `${projectPath}/${EditorConfig.paths.scripts}/${EditorConfig.paths.editorScripts}`;
}
/**
* 获取编译输出目录路径
* Get compiled output directory path
*
* @param projectPath 项目根路径 | Project root path
*/
export function getCompiledPath(projectPath: string): string {
return `${projectPath}/${EditorConfig.paths.compiled}`;
}
/**
* 获取运行时包输出路径
* Get runtime bundle output path
*
* @param projectPath 项目根路径 | Project root path
*/
export function getRuntimeBundlePath(projectPath: string): string {
return `${getCompiledPath(projectPath)}/${EditorConfig.output.runtimeBundle}`;
}
/**
* 获取编辑器包输出路径
* Get editor bundle output path
*
* @param projectPath 项目根路径 | Project root path
*/
export function getEditorBundlePath(projectPath: string): string {
return `${getCompiledPath(projectPath)}/${EditorConfig.output.editorBundle}`;
}
/**
* 获取插件目录路径
* Get plugins directory path
*
* @param projectPath 项目根路径 | Project root path
*/
export function getPluginsPath(projectPath: string): string {
return `${projectPath}/${EditorConfig.paths.plugins}`;
}
/**
* 获取项目配置文件路径
* Get project configuration file path
*
* @param projectPath 项目根路径 | Project root path
*/
export function getProjectConfigPath(projectPath: string): string {
return `${projectPath}/${EditorConfig.projectFiles.projectConfig}`;
}
/**
* 获取引擎模块目录路径
* Get engine modules directory path
*
* @param projectPath 项目根路径 | Project root path
*/
export function getEngineModulesPath(projectPath: string): string {
return `${projectPath}/${EditorConfig.paths.engineModules}`;
}
/**
* 规范化依赖 ID
* Normalize dependency ID
*
* @param depId 依赖 ID | Dependency ID
* @returns 完整的包名 | Full package name
*/
export function normalizeDependencyId(depId: string): string {
if (depId.startsWith('@')) {
return depId;
}
return `${EditorConfig.package.scope}/${depId}`;
}
/**
* 获取短依赖 ID移除作用域
* Get short dependency ID (remove scope)
*
* @param depId 依赖 ID | Dependency ID
* @returns 短 ID | Short ID
*/
export function getShortDependencyId(depId: string): string {
const prefix = `${EditorConfig.package.scope}/`;
if (depId.startsWith(prefix)) {
return depId.substring(prefix.length);
}
return depId;
}
/**
* 检查是否为引擎内置包
* Check if package is engine built-in
*
* @param packageName 包名 | Package name
*/
export function isEnginePackage(packageName: string): boolean {
return packageName.startsWith(EditorConfig.package.scope);
}
// ==================== SDK 模块辅助函数 ====================
// SDK module helper functions
/**
* 获取所有 SDK 模块配置
* Get all SDK module configurations
*/
export function getSDKModules(): readonly ISDKModuleConfig[] {
return EditorConfig.sdkModules;
}
/**
* 获取启用的 SDK 模块
* Get enabled SDK modules
*
* @param type 可选的模块类型过滤 | Optional module type filter
*/
export function getEnabledSDKModules(type?: SDKModuleType): readonly ISDKModuleConfig[] {
return EditorConfig.sdkModules.filter(m =>
m.enabled !== false && (type === undefined || m.type === type)
);
}
/**
* 获取 SDK 全局变量映射
* Get SDK global variable mapping
*
* 用于生成插件构建配置的 globals 选项。
* Used for generating plugins build config globals option.
*
* @returns 包名到全局变量的映射 | Mapping from package name to global variable
* @example
* {
* '@esengine/sdk': '__ESENGINE_SDK__',
* }
*/
export function getSDKGlobalsMapping(): Record<string, string> {
return {
'@esengine/sdk': EditorConfig.globals.sdk
};
}
/**
* 获取 SDK 包名
* Get SDK package name
*
* 用于生成插件构建配置的 external 选项。
* Used for generating plugins build config external option.
*/
export function getSDKPackageNames(): string[] {
return ['@esengine/sdk'];
}

View File

@@ -0,0 +1,5 @@
/**
* 配置模块导出
* Configuration module exports
*/
export * from './EditorConfig';

View File

@@ -0,0 +1,262 @@
/**
* Gizmo Hit Tester
* Gizmo 命中测试器
*
* Implements hit testing algorithms for various gizmo types in TypeScript.
* 在 TypeScript 端实现各种 Gizmo 类型的命中测试算法。
*/
import type {
IGizmoRenderData,
IRectGizmoData,
ICircleGizmoData,
ILineGizmoData,
ICapsuleGizmoData
} from './IGizmoProvider';
/**
* Gizmo Hit Tester
* Gizmo 命中测试器
*
* Provides static methods for testing if a point intersects with various gizmo shapes.
* 提供静态方法来测试点是否与各种 gizmo 形状相交。
*/
export class GizmoHitTester {
/** Line hit tolerance in world units (adjusted by zoom) | 线条命中容差(世界单位,根据缩放调整) */
private static readonly BASE_LINE_TOLERANCE = 8;
/**
* Test if point is inside a rect gizmo (considers rotation and origin)
* 测试点是否在矩形 gizmo 内(考虑旋转和原点)
*
* @param worldX World X coordinate | 世界 X 坐标
* @param worldY World Y coordinate | 世界 Y 坐标
* @param rect Rect gizmo data | 矩形 gizmo 数据
* @returns True if point is inside | 如果点在内部返回 true
*/
static hitTestRect(worldX: number, worldY: number, rect: IRectGizmoData): boolean {
const cx = rect.x;
const cy = rect.y;
const halfW = rect.width / 2;
const halfH = rect.height / 2;
const rotation = rect.rotation || 0;
// Transform point to rect's local coordinate system (inverse rotation)
// 将点转换到矩形的本地坐标系(逆旋转)
const cos = Math.cos(-rotation);
const sin = Math.sin(-rotation);
const dx = worldX - cx;
const dy = worldY - cy;
const localX = dx * cos - dy * sin;
const localY = dx * sin + dy * cos;
// Adjust for origin offset
// 根据原点偏移调整
const originOffsetX = (rect.originX - 0.5) * rect.width;
const originOffsetY = (rect.originY - 0.5) * rect.height;
const adjustedX = localX + originOffsetX;
const adjustedY = localY + originOffsetY;
return adjustedX >= -halfW && adjustedX <= halfW &&
adjustedY >= -halfH && adjustedY <= halfH;
}
/**
* Test if point is inside a circle gizmo
* 测试点是否在圆形 gizmo 内
*
* @param worldX World X coordinate | 世界 X 坐标
* @param worldY World Y coordinate | 世界 Y 坐标
* @param circle Circle gizmo data | 圆形 gizmo 数据
* @returns True if point is inside | 如果点在内部返回 true
*/
static hitTestCircle(worldX: number, worldY: number, circle: ICircleGizmoData): boolean {
const dx = worldX - circle.x;
const dy = worldY - circle.y;
const distSq = dx * dx + dy * dy;
return distSq <= circle.radius * circle.radius;
}
/**
* Test if point is near a line gizmo
* 测试点是否在线条 gizmo 附近
*
* @param worldX World X coordinate | 世界 X 坐标
* @param worldY World Y coordinate | 世界 Y 坐标
* @param line Line gizmo data | 线条 gizmo 数据
* @param tolerance Hit tolerance in world units | 命中容差(世界单位)
* @returns True if point is within tolerance of line | 如果点在线条容差范围内返回 true
*/
static hitTestLine(worldX: number, worldY: number, line: ILineGizmoData, tolerance: number): boolean {
const points = line.points;
if (points.length < 2) return false;
const count = line.closed ? points.length : points.length - 1;
for (let i = 0; i < count; i++) {
const p1 = points[i];
const p2 = points[(i + 1) % points.length];
if (this.pointToSegmentDistance(worldX, worldY, p1.x, p1.y, p2.x, p2.y) <= tolerance) {
return true;
}
}
return false;
}
/**
* Test if point is inside a capsule gizmo
* 测试点是否在胶囊 gizmo 内
*
* @param worldX World X coordinate | 世界 X 坐标
* @param worldY World Y coordinate | 世界 Y 坐标
* @param capsule Capsule gizmo data | 胶囊 gizmo 数据
* @returns True if point is inside | 如果点在内部返回 true
*/
static hitTestCapsule(worldX: number, worldY: number, capsule: ICapsuleGizmoData): boolean {
const cx = capsule.x;
const cy = capsule.y;
const rotation = capsule.rotation || 0;
// Transform point to capsule's local coordinate system
// 将点转换到胶囊的本地坐标系
const cos = Math.cos(-rotation);
const sin = Math.sin(-rotation);
const dx = worldX - cx;
const dy = worldY - cy;
const localX = dx * cos - dy * sin;
const localY = dx * sin + dy * cos;
// Capsule = two half-circles + middle rectangle
// 胶囊 = 两个半圆 + 中间矩形
const topCircleY = capsule.halfHeight;
const bottomCircleY = -capsule.halfHeight;
// Check if inside middle rectangle
// 检查是否在中间矩形内
if (Math.abs(localY) <= capsule.halfHeight && Math.abs(localX) <= capsule.radius) {
return true;
}
// Check if inside top half-circle
// 检查是否在上半圆内
const distToTopSq = localX * localX + (localY - topCircleY) * (localY - topCircleY);
if (distToTopSq <= capsule.radius * capsule.radius) {
return true;
}
// Check if inside bottom half-circle
// 检查是否在下半圆内
const distToBottomSq = localX * localX + (localY - bottomCircleY) * (localY - bottomCircleY);
if (distToBottomSq <= capsule.radius * capsule.radius) {
return true;
}
return false;
}
/**
* Generic hit test for any gizmo type
* 通用命中测试,适用于任何 gizmo 类型
*
* @param worldX World X coordinate | 世界 X 坐标
* @param worldY World Y coordinate | 世界 Y 坐标
* @param gizmo Gizmo data | Gizmo 数据
* @param zoom Current viewport zoom level | 当前视口缩放级别
* @returns True if point hits the gizmo | 如果点命中 gizmo 返回 true
*/
static hitTest(worldX: number, worldY: number, gizmo: IGizmoRenderData, zoom: number = 1): boolean {
// Convert screen pixel tolerance to world units
// 将屏幕像素容差转换为世界单位
const lineTolerance = this.BASE_LINE_TOLERANCE / zoom;
switch (gizmo.type) {
case 'rect':
return this.hitTestRect(worldX, worldY, gizmo);
case 'circle':
return this.hitTestCircle(worldX, worldY, gizmo);
case 'line':
return this.hitTestLine(worldX, worldY, gizmo, lineTolerance);
case 'capsule':
return this.hitTestCapsule(worldX, worldY, gizmo);
case 'grid':
// Grid typically doesn't need hit testing
// 网格通常不需要命中测试
return false;
default:
return false;
}
}
/**
* Calculate distance from point to line segment
* 计算点到线段的距离
*
* @param px Point X | 点 X
* @param py Point Y | 点 Y
* @param x1 Segment start X | 线段起点 X
* @param y1 Segment start Y | 线段起点 Y
* @param x2 Segment end X | 线段终点 X
* @param y2 Segment end Y | 线段终点 Y
* @returns Distance from point to segment | 点到线段的距离
*/
private static pointToSegmentDistance(
px: number, py: number,
x1: number, y1: number,
x2: number, y2: number
): number {
const dx = x2 - x1;
const dy = y2 - y1;
const lengthSq = dx * dx + dy * dy;
if (lengthSq === 0) {
// Segment degenerates to a point
// 线段退化为点
return Math.sqrt((px - x1) * (px - x1) + (py - y1) * (py - y1));
}
// Calculate projection parameter t
// 计算投影参数 t
let t = ((px - x1) * dx + (py - y1) * dy) / lengthSq;
t = Math.max(0, Math.min(1, t));
// Nearest point on segment
// 线段上最近的点
const nearestX = x1 + t * dx;
const nearestY = y1 + t * dy;
return Math.sqrt((px - nearestX) * (px - nearestX) + (py - nearestY) * (py - nearestY));
}
/**
* Get the center point of any gizmo
* 获取任意 gizmo 的中心点
*
* @param gizmo Gizmo data | Gizmo 数据
* @returns Center point { x, y } | 中心点 { x, y }
*/
static getGizmoCenter(gizmo: IGizmoRenderData): { x: number; y: number } {
switch (gizmo.type) {
case 'rect':
case 'circle':
case 'capsule':
return { x: gizmo.x, y: gizmo.y };
case 'line':
if (gizmo.points.length === 0) return { x: 0, y: 0 };
const sumX = gizmo.points.reduce((sum, p) => sum + p.x, 0);
const sumY = gizmo.points.reduce((sum, p) => sum + p.y, 0);
return {
x: sumX / gizmo.points.length,
y: sumY / gizmo.points.length
};
case 'grid':
return {
x: gizmo.x + gizmo.width / 2,
y: gizmo.y + gizmo.height / 2
};
default:
return { x: 0, y: 0 };
}
}
}

View File

@@ -0,0 +1,201 @@
/**
* Gizmo Registry
* Gizmo 注册表
*
* Manages gizmo providers for different component types.
* Uses registry pattern instead of prototype modification for cleaner architecture.
* 管理不同组件类型的 gizmo 提供者。
* 使用注册表模式替代原型修改,实现更清晰的架构。
*/
import { createLogger, type Component, type ComponentType, type Entity } from '@esengine/ecs-framework';
import type { IGizmoProvider, IGizmoRenderData } from './IGizmoProvider';
const logger = createLogger('GizmoRegistry');
/**
* Gizmo provider function type
* Gizmo 提供者函数类型
*
* A function that generates gizmo data for a specific component instance.
* 为特定组件实例生成 gizmo 数据的函数。
*/
export type GizmoProviderFn<T extends Component = Component> = (
component: T,
entity: Entity,
isSelected: boolean
) => IGizmoRenderData[];
/**
* Gizmo Registry Service
* Gizmo 注册表服务
*
* Centralized registry for component gizmo providers.
* Allows plugins to register gizmo rendering for any component type
* without modifying the component class itself.
*
* 组件 gizmo 提供者的中心化注册表。
* 允许插件为任何组件类型注册 gizmo 渲染,
* 而无需修改组件类本身。
*
* @example
* ```typescript
* // Register a gizmo provider for SpriteComponent
* GizmoRegistry.register(SpriteComponent, (sprite, entity, isSelected) => {
* const transform = entity.getComponent(TransformComponent);
* return [{
* type: 'rect',
* x: transform.position.x,
* y: transform.position.y,
* width: sprite.width,
* height: sprite.height,
* // ...
* }];
* });
*
* // Get gizmo data for a component
* const gizmos = GizmoRegistry.getGizmoData(spriteComponent, entity, true);
* ```
*/
export class GizmoRegistry {
private static providers = new Map<ComponentType, GizmoProviderFn>();
/**
* Register a gizmo provider for a component type.
* 为组件类型注册 gizmo 提供者。
*
* @param componentType - The component class to register for
* @param provider - Function that generates gizmo data
*/
static register<T extends Component>(
componentType: ComponentType<T>,
provider: GizmoProviderFn<T>
): void {
this.providers.set(componentType, provider as GizmoProviderFn);
}
/**
* Unregister a gizmo provider for a component type.
* 取消注册组件类型的 gizmo 提供者。
*
* @param componentType - The component class to unregister
*/
static unregister(componentType: ComponentType): void {
this.providers.delete(componentType);
}
/**
* Check if a component type has a registered gizmo provider.
* 检查组件类型是否有注册的 gizmo 提供者。
*
* @param componentType - The component class to check
*/
static hasProvider(componentType: ComponentType): boolean {
return this.providers.has(componentType);
}
/**
* Get the gizmo provider for a component type.
* 获取组件类型的 gizmo 提供者。
*
* @param componentType - The component class
* @returns The provider function or undefined
*/
static getProvider(componentType: ComponentType): GizmoProviderFn | undefined {
return this.providers.get(componentType);
}
/**
* Get gizmo data for a component instance.
* 获取组件实例的 gizmo 数据。
*
* @param component - The component instance
* @param entity - The entity owning the component
* @param isSelected - Whether the entity is selected
* @returns Array of gizmo render data, or empty array if no provider
*/
static getGizmoData(
component: Component,
entity: Entity,
isSelected: boolean
): IGizmoRenderData[] {
const componentType = component.constructor as ComponentType;
const provider = this.providers.get(componentType);
if (provider) {
try {
return provider(component, entity, isSelected);
} catch (e) {
logger.warn(`Error in gizmo provider for ${componentType.name}:`, e);
return [];
}
}
return [];
}
/**
* Get all gizmo data for an entity (from all components with providers).
* 获取实体的所有 gizmo 数据(来自所有有提供者的组件)。
*
* @param entity - The entity to get gizmos for
* @param isSelected - Whether the entity is selected
* @returns Array of all gizmo render data
*/
static getAllGizmoDataForEntity(entity: Entity, isSelected: boolean): IGizmoRenderData[] {
const allGizmos: IGizmoRenderData[] = [];
for (const component of entity.components) {
const gizmos = this.getGizmoData(component, entity, isSelected);
allGizmos.push(...gizmos);
}
return allGizmos;
}
/**
* Check if an entity has any components with gizmo providers.
* 检查实体是否有任何带有 gizmo 提供者的组件。
*
* @param entity - The entity to check
*/
static hasAnyGizmoProvider(entity: Entity): boolean {
for (const component of entity.components) {
const componentType = component.constructor as ComponentType;
if (this.providers.has(componentType)) {
return true;
}
}
return false;
}
/**
* Get all registered component types.
* 获取所有已注册的组件类型。
*/
static getRegisteredTypes(): ComponentType[] {
return Array.from(this.providers.keys());
}
/**
* Clear all registered providers.
* 清除所有已注册的提供者。
*/
static clear(): void {
this.providers.clear();
}
}
/**
* Adapter to make GizmoRegistry work with the IGizmoProvider interface.
* 使 GizmoRegistry 与 IGizmoProvider 接口兼容的适配器。
*
* This allows components to optionally implement IGizmoProvider directly,
* while also supporting the registry pattern.
* 这允许组件可选地直接实现 IGizmoProvider
* 同时也支持注册表模式。
*/
export function isGizmoProviderRegistered(component: Component): boolean {
const componentType = component.constructor as ComponentType;
return GizmoRegistry.hasProvider(componentType);
}

View File

@@ -0,0 +1,218 @@
/**
* Gizmo Provider Interface
* Gizmo 提供者接口
*
* Allows components to define custom gizmo rendering in the editor.
* Uses the Rust WebGL renderer for high-performance gizmo display.
* 允许组件定义编辑器中的自定义 gizmo 渲染。
* 使用 Rust WebGL 渲染器实现高性能 gizmo 显示。
*/
import type { Entity } from '@esengine/ecs-framework';
/**
* Gizmo type enumeration
* Gizmo 类型枚举
*/
export type GizmoType = 'rect' | 'circle' | 'line' | 'grid' | 'capsule';
/**
* Color in RGBA format (0-1 range)
* RGBA 格式颜色0-1 范围)
*/
export interface GizmoColor {
r: number;
g: number;
b: number;
a: number;
}
/**
* Base gizmo data with optional virtual node reference
* 带有可选虚拟节点引用的基础 Gizmo 数据
*/
export interface IGizmoDataBase {
/**
* Optional virtual node ID for component internal nodes
* 可选的虚拟节点 ID用于组件内部节点
*
* When set, clicking this gizmo will select the virtual node
* instead of just the entity.
* 设置后,点击此 gizmo 将选中虚拟节点而不只是实体。
*/
virtualNodeId?: string;
}
/**
* Rectangle gizmo data (rendered via Rust WebGL)
* 矩形 gizmo 数据(通过 Rust WebGL 渲染)
*/
export interface IRectGizmoData extends IGizmoDataBase {
type: 'rect';
/** Center X position in world space | 世界空间中心 X 位置 */
x: number;
/** Center Y position in world space | 世界空间中心 Y 位置 */
y: number;
/** Width in world units | 世界单位宽度 */
width: number;
/** Height in world units | 世界单位高度 */
height: number;
/** Rotation in radians | 旋转角度(弧度) */
rotation: number;
/** Origin X (0-1, default 0.5 for center) | 原点 X0-1默认 0.5 居中) */
originX: number;
/** Origin Y (0-1, default 0.5 for center) | 原点 Y0-1默认 0.5 居中) */
originY: number;
/** Color | 颜色 */
color: GizmoColor;
/** Show transform handles (move/rotate/scale based on mode) | 显示变换手柄 */
showHandles: boolean;
}
/**
* Circle gizmo data
* 圆形 gizmo 数据
*/
export interface ICircleGizmoData extends IGizmoDataBase {
type: 'circle';
/** Center X position | 中心 X 位置 */
x: number;
/** Center Y position | 中心 Y 位置 */
y: number;
/** Radius | 半径 */
radius: number;
/** Color | 颜色 */
color: GizmoColor;
}
/**
* Line gizmo data
* 线条 gizmo 数据
*/
export interface ILineGizmoData extends IGizmoDataBase {
type: 'line';
/** Line points | 线段点 */
points: Array<{ x: number; y: number }>;
/** Color | 颜色 */
color: GizmoColor;
/** Whether to close the path | 是否闭合路径 */
closed: boolean;
}
/**
* Grid gizmo data
* 网格 gizmo 数据
*/
export interface IGridGizmoData extends IGizmoDataBase {
type: 'grid';
/** Top-left X position | 左上角 X 位置 */
x: number;
/** Top-left Y position | 左上角 Y 位置 */
y: number;
/** Total width | 总宽度 */
width: number;
/** Total height | 总高度 */
height: number;
/** Number of columns | 列数 */
cols: number;
/** Number of rows | 行数 */
rows: number;
/** Color | 颜色 */
color: GizmoColor;
}
/**
* Capsule gizmo data
* 胶囊 gizmo 数据
*/
export interface ICapsuleGizmoData extends IGizmoDataBase {
type: 'capsule';
/** Center X position | 中心 X 位置 */
x: number;
/** Center Y position | 中心 Y 位置 */
y: number;
/** Capsule radius | 胶囊半径 */
radius: number;
/** Half height (distance from center to cap centers) | 半高度(从中心到端帽圆心的距离) */
halfHeight: number;
/** Rotation in radians | 旋转角度(弧度) */
rotation: number;
/** Color | 颜色 */
color: GizmoColor;
}
/**
* Union type for all gizmo data
* 所有 gizmo 数据的联合类型
*/
export type IGizmoRenderData = IRectGizmoData | ICircleGizmoData | ILineGizmoData | IGridGizmoData | ICapsuleGizmoData;
/**
* Gizmo Provider Interface
* Gizmo 提供者接口
*
* Components can implement this interface to provide custom gizmo rendering.
* The returned data will be rendered by the Rust WebGL engine.
* 组件可以实现此接口以提供自定义 gizmo 渲染。
* 返回的数据将由 Rust WebGL 引擎渲染。
*/
export interface IGizmoProvider {
/**
* Get gizmo render data for this component
* 获取此组件的 gizmo 渲染数据
*
* @param entity The entity owning this component | 拥有此组件的实体
* @param isSelected Whether the entity is selected | 实体是否被选中
* @returns Array of gizmo render data | Gizmo 渲染数据数组
*/
getGizmoData(entity: Entity, isSelected: boolean): IGizmoRenderData[];
}
/**
* Check if a component implements IGizmoProvider
* 检查组件是否实现了 IGizmoProvider
*/
export function hasGizmoProvider(component: unknown): component is IGizmoProvider {
return component !== null &&
typeof component === 'object' &&
'getGizmoData' in component &&
typeof (component as Record<string, unknown>).getGizmoData === 'function';
}
/**
* Helper to create a gizmo color from hex string
* 从十六进制字符串创建 gizmo 颜色的辅助函数
*/
export function hexToGizmoColor(hex: string, alpha: number = 1): GizmoColor {
let r = 0, g = 1, b = 0;
if (hex.startsWith('#')) {
const hexValue = hex.slice(1);
if (hexValue.length === 3) {
r = parseInt(hexValue[0] + hexValue[0], 16) / 255;
g = parseInt(hexValue[1] + hexValue[1], 16) / 255;
b = parseInt(hexValue[2] + hexValue[2], 16) / 255;
} else if (hexValue.length === 6) {
r = parseInt(hexValue.slice(0, 2), 16) / 255;
g = parseInt(hexValue.slice(2, 4), 16) / 255;
b = parseInt(hexValue.slice(4, 6), 16) / 255;
}
}
return { r, g, b, a: alpha };
}
/**
* Predefined gizmo colors
* 预定义的 gizmo 颜色
*/
export const GizmoColors = {
/** Green for selected entities | 选中实体的绿色 */
selected: { r: 0, g: 1, b: 0.5, a: 1 } as GizmoColor,
/** Semi-transparent green for unselected | 未选中实体的半透明绿色 */
unselected: { r: 0, g: 1, b: 0.5, a: 0.4 } as GizmoColor,
/** White for camera frustum | 相机视锥体的白色 */
camera: { r: 1, g: 1, b: 1, a: 0.8 } as GizmoColor,
/** Cyan for colliders | 碰撞体的青色 */
collider: { r: 0, g: 1, b: 1, a: 0.6 } as GizmoColor,
/** Yellow for grid | 网格的黄色 */
grid: { r: 1, g: 1, b: 0, a: 0.3 } as GizmoColor,
};

View File

@@ -0,0 +1,13 @@
/**
* Gizmo System
* Gizmo 系统
*
* Provides interfaces for custom gizmo rendering in the editor.
* Gizmos are rendered by the Rust WebGL engine for optimal performance.
* 为编辑器中的自定义 gizmo 渲染提供接口。
* Gizmo 由 Rust WebGL 引擎渲染以获得最佳性能。
*/
export * from './IGizmoProvider';
export * from './GizmoRegistry';
export * from './GizmoHitTester';

View File

@@ -0,0 +1,25 @@
export interface KeyBinding {
key: string;
ctrl?: boolean;
alt?: boolean;
shift?: boolean;
meta?: boolean;
}
export interface IUICommand {
readonly id: string;
readonly label: string;
readonly icon?: string;
readonly keybinding?: KeyBinding;
readonly when?: () => boolean;
execute(context?: unknown): void | Promise<void>;
}
export interface ICommandRegistry {
register(command: IUICommand): void;
unregister(commandId: string): void;
execute(commandId: string, context?: unknown): Promise<void>;
getCommand(commandId: string): IUICommand | undefined;
getCommands(): IUICommand[];
getKeybindings(): Array<{ command: IUICommand; keybinding: KeyBinding }>;
}

View File

@@ -0,0 +1,12 @@
import type { IModuleContext } from './IModuleContext';
export interface IEditorModule<TEvents = Record<string, unknown>> {
readonly id: string;
readonly name: string;
readonly version?: string;
readonly dependencies?: string[];
load(context: IModuleContext<TEvents>): Promise<void>;
unload?(): Promise<void>;
reload?(): Promise<void>;
}

View File

@@ -0,0 +1,16 @@
import type { Observable } from 'rxjs';
export type Unsubscribe = () => void;
export interface IEventBus<TEvents = Record<string, unknown>> {
publish<K extends keyof TEvents>(topic: K, data: TEvents[K]): Promise<void>;
subscribe<K extends keyof TEvents>(
topic: K,
handler: (data: TEvents[K]) => void | Promise<void>
): Unsubscribe;
observe<K extends keyof TEvents>(topic: K): Observable<TEvents[K]>;
dispose(): void;
}

View File

@@ -0,0 +1,19 @@
import type { DependencyContainer } from 'tsyringe';
import type { IEventBus } from './IEventBus';
import type { ICommandRegistry } from './ICommandRegistry';
import type { IPanelRegistry } from './IPanelRegistry';
import type { IFileSystem } from '../Services/IFileSystem';
import type { IDialog } from '../Services/IDialog';
import type { INotification } from '../Services/INotification';
import type { InspectorRegistry } from '../Services/InspectorRegistry';
export interface IModuleContext<TEvents = Record<string, unknown>> {
readonly container: DependencyContainer;
readonly eventBus: IEventBus<TEvents>;
readonly commands: ICommandRegistry;
readonly panels: IPanelRegistry;
readonly fileSystem: IFileSystem;
readonly dialog: IDialog;
readonly notification: INotification;
readonly inspectorRegistry: InspectorRegistry;
}

View File

@@ -0,0 +1,8 @@
import type { PanelDescriptor } from '../Types/UITypes';
export interface IPanelRegistry {
register(panel: PanelDescriptor): void;
unregister(panelId: string): void;
getPanel(panelId: string): PanelDescriptor | undefined;
getPanels(category?: string): PanelDescriptor[];
}

View File

@@ -0,0 +1,340 @@
/**
* 编辑器模块接口
* Editor module interfaces
*
* 定义编辑器专用的模块接口和 UI 描述符类型。
* Define editor-specific module interfaces and UI descriptor types.
*
* IEditorModuleLoader 扩展自 engine-core 的 IEditorModuleBase。
* IEditorModuleLoader extends IEditorModuleBase from engine-core.
*/
// 从 PluginDescriptor 重新导出(来源于 engine-core
// 统一从 engine-core 导入所有类型,避免直接依赖 plugin-types
export type {
LoadingPhase,
SystemContext,
IRuntimeModule,
IRuntimePlugin,
ModuleManifest,
ModuleCategory,
ModulePlatform,
ModuleExports,
IEditorModuleBase
} from './PluginDescriptor';
// ============================================================================
// UI 描述符类型 | UI Descriptor Types
// ============================================================================
/**
* 面板位置
* Panel position
*/
export enum PanelPosition {
Left = 'left',
Right = 'right',
Bottom = 'bottom',
Center = 'center'
}
/**
* 面板描述符
* Panel descriptor
*/
export interface PanelDescriptor {
/** 面板ID | Panel ID */
id: string;
/**
* 面板标题 | Panel title
* 作为默认/英文标题,当 titleKey 未设置或翻译缺失时使用
* Used as default/English title when titleKey is not set or translation is missing
*/
title: string;
/**
* 面板标题翻译键 | Panel title translation key
* 设置后会根据当前语言自动翻译
* When set, title will be automatically translated based on current locale
* @example 'panel.behaviorTreeEditor'
*/
titleKey?: string;
/** 面板图标 | Panel icon */
icon?: string;
/** 面板位置 | Panel position */
position: PanelPosition;
/** 渲染组件 | Render component */
component?: any;
/** 渲染函数 | Render function */
render?: () => any;
/** 默认大小 | Default size */
defaultSize?: number;
/** 是否可调整大小 | Is resizable */
resizable?: boolean;
/** 是否可关闭 | Is closable */
closable?: boolean;
/** 排序权重 | Order weight */
order?: number;
/** 是否为动态面板 | Is dynamic panel */
isDynamic?: boolean;
}
/**
* 菜单项描述符
* Menu item descriptor
*/
export interface MenuItemDescriptor {
/** 菜单ID | Menu ID */
id: string;
/** 菜单标签 | Menu label */
label: string;
/** 父菜单ID | Parent menu ID */
parentId?: string;
/** 图标 | Icon */
icon?: string;
/** 快捷键 | Shortcut */
shortcut?: string;
/** 执行函数 | Execute function */
execute?: () => void;
/** 子菜单 | Submenu items */
children?: MenuItemDescriptor[];
}
/**
* 工具栏项描述符
* Toolbar item descriptor
*/
export interface ToolbarItemDescriptor {
/** 工具栏项ID | Toolbar item ID */
id: string;
/** 标签 | Label */
label: string;
/** 图标 | Icon */
icon: string;
/** 提示 | Tooltip */
tooltip?: string;
/** 执行函数 | Execute function */
execute: () => void;
}
/**
* 组件检视器提供者
* Component inspector provider
*/
export interface ComponentInspectorProviderDef {
/** 组件类型名 | Component type name */
componentType: string;
/** 优先级 | Priority */
priority?: number;
/** 渲染函数 | Render function */
render: (component: any, entity: any, onChange: (key: string, value: any) => void) => any;
}
/**
* Gizmo 提供者注册
* Gizmo provider registration
*/
export interface GizmoProviderRegistration {
/** 组件类型 | Component type */
componentType: any;
/** 获取 Gizmo 数据 | Get gizmo data */
getGizmoData: (component: any, entity: any, isSelected: boolean) => any[];
}
/**
* 文件操作处理器
* File action handler
*/
export interface FileActionHandler {
/** 支持的文件扩展名 | Supported file extensions */
extensions: string[];
/** 双击处理 | Double click handler */
onDoubleClick?: (filePath: string) => void | Promise<void>;
/** 打开处理 | Open handler */
onOpen?: (filePath: string) => void | Promise<void>;
/** 获取上下文菜单 | Get context menu */
getContextMenuItems?: (filePath: string, parentPath: string) => any[];
}
/**
* 实体创建模板
* Entity creation template
*/
export interface EntityCreationTemplate {
/** 模板ID | Template ID */
id: string;
/** 标签 | Label */
label: string;
/** 图标组件 | Icon component */
icon?: any;
/** 分类 | Category */
category?: string;
/** 排序权重 | Order weight */
order?: number;
/** 创建函数 | Create function */
create: (parentEntityId?: number) => number | Promise<number>;
}
/**
* 组件操作
* Component action
*/
export interface ComponentAction {
/** 操作ID | Action ID */
id: string;
/** 组件名 | Component name */
componentName: string;
/** 标签 | Label */
label: string;
/** 图标 | Icon */
icon?: any;
/** 排序权重 | Order weight */
order?: number;
/** 执行函数 | Execute function */
execute: (component: any, entity: any) => void | Promise<void>;
}
/**
* 序列化器接口
* Serializer interface
*/
export interface ISerializer<T = any> {
/** 获取支持的类型 | Get supported type */
getSupportedType(): string;
/** 序列化数据 | Serialize data */
serialize(data: T): Uint8Array;
/** 反序列化数据 | Deserialize data */
deserialize(data: Uint8Array): T;
}
/**
* 文件创建模板
* File creation template
*/
export interface FileCreationTemplate {
/** 模板ID | Template ID */
id: string;
/** 标签 | Label */
label: string;
/** 扩展名 | Extension */
extension: string;
/** 图标 | Icon */
icon?: string;
/** 分类 | Category */
category?: string;
/**
* 获取文件内容 | Get file content
* @param fileName 文件名(不含路径,含扩展名)
* @returns 文件内容字符串
*/
getContent: (fileName: string) => string | Promise<string>;
}
// ============================================================================
// 编辑器模块接口 | Editor Module Interface
// ============================================================================
import type { IEditorModuleBase } from './PluginDescriptor';
/**
* 编辑器模块加载器
* Editor module loader
*
* 扩展 IEditorModuleBase 并添加编辑器特定的 UI 描述符方法。
* Extends IEditorModuleBase and adds editor-specific UI descriptor methods.
*
* 生命周期方法继承自 IEditorModuleBase:
* Lifecycle methods inherited from IEditorModuleBase:
* - install(services) / uninstall()
* - onEditorReady() / onProjectOpen() / onProjectClose()
* - onSceneLoaded() / onSceneSaving()
* - setLocale()
*/
export interface IEditorModuleLoader extends IEditorModuleBase {
/**
* 返回面板描述列表
* Get panel descriptors
*/
getPanels?(): PanelDescriptor[];
/**
* 返回菜单项列表
* Get menu items
*/
getMenuItems?(): MenuItemDescriptor[];
/**
* 返回工具栏项列表
* Get toolbar items
*/
getToolbarItems?(): ToolbarItemDescriptor[];
/**
* 返回检视器提供者列表
* Get inspector providers
*/
getInspectorProviders?(): ComponentInspectorProviderDef[];
/**
* 返回 Gizmo 提供者列表
* Get gizmo providers
*/
getGizmoProviders?(): GizmoProviderRegistration[];
/**
* 返回文件操作处理器列表
* Get file action handlers
*/
getFileActionHandlers?(): FileActionHandler[];
/**
* 返回实体创建模板列表
* Get entity creation templates
*/
getEntityCreationTemplates?(): EntityCreationTemplate[];
/**
* 返回组件操作列表
* Get component actions
*/
getComponentActions?(): ComponentAction[];
/**
* 返回文件创建模板列表
* Get file creation templates
*/
getFileCreationTemplates?(): FileCreationTemplate[];
}
// ============================================================================
// 编辑器插件类型 | Editor Plugin Type
// ============================================================================
import type { IRuntimePlugin } from './PluginDescriptor';
/**
* 编辑器插件类型
* Editor plugin type
*
* 这是开发编辑器插件时应使用的类型。
* 它是 IRuntimePlugin 的特化版本editorModule 类型为 IEditorModuleLoader。
*
* This is the type to use when developing editor plugins.
* It's a specialized version of IRuntimePlugin with editorModule typed as IEditorModuleLoader.
*
* @example
* ```typescript
* import { IEditorPlugin, IEditorModuleLoader } from '@esengine/editor-core';
*
* class MyEditorModule implements IEditorModuleLoader {
* async install(services) { ... }
* getPanels() { return [...]; }
* }
*
* export const MyPlugin: IEditorPlugin = {
* manifest,
* runtimeModule: new MyRuntimeModule(),
* editorModule: new MyEditorModule()
* };
* ```
*/
export type IEditorPlugin = IRuntimePlugin<IEditorModuleLoader>;

View File

@@ -0,0 +1,33 @@
/**
* 插件/模块类型定义
* Plugin/Module type definitions
*
* 从 @esengine/engine-core 重新导出基础类型。
* Re-export base types from @esengine/engine-core.
*/
// 从 engine-core 重新导出所有类型
// 包括 IEditorModuleBase原来在 plugin-types 中定义,现在统一从 engine-core 导出)
export type {
LoadingPhase,
SystemContext,
IRuntimeModule,
IRuntimePlugin,
ModuleManifest,
ModuleCategory,
ModulePlatform,
ModuleExports,
IEditorModuleBase
} from '@esengine/engine-core';
/**
* 插件状态
* Plugin state
*/
export type PluginState =
| 'unloaded' // 未加载 | Not loaded
| 'loading' // 加载中 | Loading
| 'loaded' // 已加载 | Loaded
| 'active' // 已激活 | Active
| 'error' // 错误 | Error
| 'disabled'; // 已禁用 | Disabled

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,8 @@
/**
* 插件系统
* Plugin System
*/
export * from './PluginDescriptor';
export * from './EditorModule';
export * from './PluginManager';

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

@@ -0,0 +1,989 @@
/**
* Asset Registry Service
* 资产注册表服务
*
* 负责扫描项目资产目录为每个资产生成唯一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, createLogger, PlatformDetector, type IService } from '@esengine/ecs-framework';
import { MessageHub } from './MessageHub';
import {
AssetMetaManager,
IAssetMeta,
IMetaFileSystem,
inferAssetType
} from '@esengine/asset-system-editor';
import type { IFileSystem, FileEntry } from './IFileSystem';
import { IFileSystemService } from './IFileSystem';
// Logger for AssetRegistry using core's logger
const logger = createLogger('AssetRegistry');
/**
* Asset GUID type (simplified, no dependency on asset-system)
*/
export type AssetGUID = string;
/**
* Asset type for registry (using different name to avoid conflict)
*/
export type AssetRegistryType = string;
/**
* Asset metadata (simplified)
*/
export interface IAssetRegistryMetadata {
guid: AssetGUID;
path: string;
type: AssetRegistryType;
name: string;
size: number;
hash: string;
lastModified: number;
}
/**
* Asset catalog entry for export
*/
export interface IAssetRegistryCatalogEntry {
guid: AssetGUID;
path: string;
type: AssetRegistryType;
size: number;
hash: string;
}
/**
* Asset file info from filesystem scan
*/
export interface AssetFileInfo {
/** Absolute path to the file */
absolutePath: string;
/** Path relative to project root */
relativePath: string;
/** File name without extension */
name: string;
/** File extension (e.g., '.png', '.btree') */
extension: string;
/** File size in bytes */
size: number;
/** Last modified timestamp */
lastModified: number;
}
/**
* Asset registry manifest stored in project
* 存储在项目中的资产注册表清单
*/
export interface AssetManifest {
version: string;
createdAt: number;
updatedAt: number;
assets: Record<string, AssetManifestEntry>;
}
/**
* Single asset entry in manifest
*/
export interface AssetManifestEntry {
guid: AssetGUID;
relativePath: string;
type: AssetRegistryType;
hash?: string;
}
/**
* Extension to asset type mapping
*/
const EXTENSION_TYPE_MAP: Record<string, AssetRegistryType> = {
// Textures
'.png': 'texture',
'.jpg': 'texture',
'.jpeg': 'texture',
'.webp': 'texture',
'.gif': 'texture',
// Audio
'.mp3': 'audio',
'.ogg': 'audio',
'.wav': 'audio',
// Data
'.json': 'json',
'.txt': 'text',
// Scripts
'.ts': 'script',
'.js': 'script',
// Custom types
'.btree': 'btree',
'.ecs': 'scene',
'.prefab': 'prefab',
'.tmx': 'tilemap',
'.tsx': 'tileset',
// Particle system
'.particle': 'particle',
// FairyGUI
'.fui': 'fui',
};
/**
* Directories managed by asset registry (GUID system)
* 被资产注册表GUID 系统)管理的目录
*/
export const MANAGED_ASSET_DIRECTORIES = ['assets', 'scripts', 'scenes'] as const;
export type ManagedAssetDirectory = typeof MANAGED_ASSET_DIRECTORIES[number];
// 使用从 IFileSystem.ts 导入的标准接口
// Using standard interface imported from IFileSystem.ts
/**
* Simple in-memory asset database
*/
class SimpleAssetDatabase {
private readonly _metadata = new Map<AssetGUID, IAssetRegistryMetadata>();
private readonly _pathToGuid = new Map<string, AssetGUID>();
private readonly _typeToGuids = new Map<AssetRegistryType, Set<AssetGUID>>();
addAsset(metadata: IAssetRegistryMetadata): void {
const { guid, type } = metadata;
// Normalize path separators for consistent storage
const normalizedPath = metadata.path.replace(/\\/g, '/');
const normalizedMetadata = { ...metadata, path: normalizedPath };
this._metadata.set(guid, normalizedMetadata);
this._pathToGuid.set(normalizedPath, guid);
if (!this._typeToGuids.has(type)) {
this._typeToGuids.set(type, new Set());
}
this._typeToGuids.get(type)!.add(guid);
}
removeAsset(guid: AssetGUID): void {
const metadata = this._metadata.get(guid);
if (!metadata) return;
this._metadata.delete(guid);
// Path is already normalized when stored
this._pathToGuid.delete(metadata.path);
const typeSet = this._typeToGuids.get(metadata.type);
if (typeSet) {
typeSet.delete(guid);
}
}
getMetadata(guid: AssetGUID): IAssetRegistryMetadata | undefined {
return this._metadata.get(guid);
}
getMetadataByPath(path: string): IAssetRegistryMetadata | undefined {
// Normalize path separators for consistent lookup
const normalizedPath = path.replace(/\\/g, '/');
const guid = this._pathToGuid.get(normalizedPath);
return guid ? this._metadata.get(guid) : undefined;
}
findAssetsByType(type: AssetRegistryType): AssetGUID[] {
const guids = this._typeToGuids.get(type);
return guids ? Array.from(guids) : [];
}
exportToCatalog(): IAssetRegistryCatalogEntry[] {
const entries: IAssetRegistryCatalogEntry[] = [];
this._metadata.forEach((metadata) => {
entries.push({
guid: metadata.guid,
path: metadata.path,
type: metadata.type,
size: metadata.size,
hash: metadata.hash
});
});
return entries;
}
getStatistics(): { totalAssets: number } {
return { totalAssets: this._metadata.size };
}
clear(): void {
this._metadata.clear();
this._pathToGuid.clear();
this._typeToGuids.clear();
}
}
/**
* Asset Registry Service
* 资产注册表服务
*
* 实现 IService 接口以便与 ServiceContainer 集成
* Implements IService interface for ServiceContainer integration
*/
export class AssetRegistryService implements IService {
private _database: SimpleAssetDatabase;
private _projectPath: string | null = null;
private _manifest: AssetManifest | null = null;
private _fileSystem: IFileSystem | null = null;
private _messageHub: MessageHub | null = null;
private _initialized = false;
/** Asset meta manager for .meta file management */
private _metaManager: AssetMetaManager;
/** Tauri event unlisten function | Tauri 事件取消监听函数 */
private _eventUnlisten: (() => void) | undefined;
/** Manifest file name */
static readonly MANIFEST_FILE = 'asset-manifest.json';
/** Current manifest version */
static readonly MANIFEST_VERSION = '1.0.0';
constructor() {
this._database = new SimpleAssetDatabase();
this._metaManager = new AssetMetaManager();
}
/**
* Get the AssetMetaManager instance
* 获取 AssetMetaManager 实例
*/
get metaManager(): AssetMetaManager {
return this._metaManager;
}
/**
* Initialize the service
*/
async initialize(): Promise<void> {
if (this._initialized) return;
// Get file system service using the exported Symbol
// 使用导出的 Symbol 获取文件系统服务
this._fileSystem = Core.services.tryResolve(IFileSystemService) as IFileSystem | null;
// Get message hub
this._messageHub = Core.services.tryResolve(MessageHub) as MessageHub | null;
// Subscribe to project events
if (this._messageHub) {
this._messageHub.subscribe('project:opened', this._onProjectOpened.bind(this));
this._messageHub.subscribe('project:closed', this._onProjectClosed.bind(this));
} else {
logger.warn('MessageHub not available, cannot subscribe to project events');
}
this._initialized = true;
}
/**
* Handle project opened event
*/
private async _onProjectOpened(data: { path: string }): Promise<void> {
await this.loadProject(data.path);
}
/**
* Handle project closed event
* 处理项目关闭事件
*/
private async _onProjectClosed(): Promise<void> {
await this.unloadProject();
}
/**
* Load project and scan assets
*/
async loadProject(projectPath: string): Promise<void> {
if (!this._fileSystem) {
logger.warn('FileSystem service not available, skipping asset registry');
return;
}
this._projectPath = projectPath;
this._database.clear();
this._metaManager.clear();
// Setup MetaManager with file system adapter
// 设置 MetaManager 的文件系统适配器
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 using deleteFile
// 尝试使用 deleteFile 删除
try {
await this._fileSystem!.deleteFile(path);
} catch {
// Ignore delete errors
}
}
};
this._metaManager.setFileSystem(metaFs);
// Try to load existing manifest (for backward compatibility)
await this._loadManifest();
// Scan assets directory (now uses .meta files)
await this._scanAssetsDirectory();
// Save updated manifest
await this._saveManifest();
// Subscribe to file change events (Tauri only)
// 订阅文件变化事件(仅 Tauri 环境)
await this._subscribeToFileChanges();
logger.info(`Project assets loaded: ${this._database.getStatistics().totalAssets} assets`);
// Publish event
this._messageHub?.publish('assets:registry:loaded', {
projectPath,
assetCount: this._database.getStatistics().totalAssets
});
}
/**
* Subscribe to file change events from Tauri backend
* 订阅来自 Tauri 后端的文件变化事件
*/
private async _subscribeToFileChanges(): Promise<void> {
// Only in Tauri environment
// 仅在 Tauri 环境中
if (!PlatformDetector.isTauriEnvironment()) {
return;
}
try {
const { listen } = await import('@tauri-apps/api/event');
const { invoke } = await import('@tauri-apps/api/core');
// Start asset watcher for managed directories (assets, scenes)
// 启动资产监视器监听托管目录assets, scenes
// Note: scripts is watched by UserCodeService
// 注意scripts 目录由 UserCodeService 监听
const directoriesToWatch = MANAGED_ASSET_DIRECTORIES.filter(dir => dir !== 'scripts');
if (this._projectPath && directoriesToWatch.length > 0) {
try {
await invoke('watch_assets', {
projectPath: this._projectPath,
directories: directoriesToWatch
});
logger.info(`Started watching asset directories | 已启动资产目录监听: ${directoriesToWatch.join(', ')}`);
} catch (watchError) {
logger.warn('Failed to start asset watcher | 启动资产监视器失败:', watchError);
}
}
// Listen to user-code:file-changed event
// 监听 user-code:file-changed 事件
this._eventUnlisten = await listen<{
changeType: string;
paths: string[];
}>('user-code:file-changed', async (event) => {
const { changeType, paths } = event.payload;
logger.debug('File change event received | 收到文件变化事件', { changeType, paths });
// Handle file creation - register new assets and generate .meta
// 处理文件创建 - 注册新资产并生成 .meta
if (changeType === 'create' || changeType === 'modify') {
for (const absolutePath of paths) {
// Handle .meta file changes - invalidate cache and notify listeners
// 处理 .meta 文件变化 - 使缓存失效并通知监听者
if (absolutePath.endsWith('.meta')) {
const assetPath = absolutePath.slice(0, -5); // Remove '.meta' suffix
this._metaManager.invalidateCache(assetPath);
logger.debug(`Meta file changed, invalidated cache for: ${assetPath}`);
// Notify listeners that the asset's metadata has changed
// 通知监听者资产的元数据已变化
const relativePath = this.absoluteToRelative(assetPath);
if (relativePath) {
const metadata = this._database.getMetadataByPath(relativePath);
if (metadata) {
this._messageHub?.publish('assets:changed', {
type: 'modify',
path: assetPath,
relativePath,
guid: metadata.guid
});
logger.debug(`Published assets:changed for meta file: ${relativePath}`);
}
}
continue;
}
// Only process files in managed directories
// 只处理托管目录中的文件
if (!this.isPathManaged(absolutePath)) continue;
// Register or refresh the asset
await this.registerAsset(absolutePath);
}
} else if (changeType === 'remove') {
for (const absolutePath of paths) {
// Handle .meta file deletion - invalidate cache
// 处理 .meta 文件删除 - 使缓存失效
if (absolutePath.endsWith('.meta')) {
const assetPath = absolutePath.slice(0, -5);
this._metaManager.invalidateCache(assetPath);
logger.debug(`Meta file removed, invalidated cache for: ${assetPath}`);
continue;
}
// Only process files in managed directories
// 只处理托管目录中的文件
if (!this.isPathManaged(absolutePath)) continue;
// Unregister the asset
await this.unregisterAsset(absolutePath);
}
}
});
logger.info('Subscribed to file change events | 已订阅文件变化事件');
} catch (error) {
logger.warn('Failed to subscribe to file change events | 订阅文件变化事件失败:', error);
}
}
/**
* Unsubscribe from file change events
* 取消订阅文件变化事件
*/
private async _unsubscribeFromFileChanges(): Promise<void> {
if (this._eventUnlisten) {
this._eventUnlisten();
this._eventUnlisten = undefined;
logger.debug('Unsubscribed from file change events | 已取消订阅文件变化事件');
}
// Stop the asset watcher | 停止资产监视器
if (PlatformDetector.isTauriEnvironment() && this._projectPath) {
try {
const { invoke } = await import('@tauri-apps/api/core');
// Stop watcher using the same key format used in watch_assets
// 使用与 watch_assets 相同的键格式停止监视器
await invoke('stop_watch_scripts', {
projectPath: `${this._projectPath}/assets`
});
logger.debug('Stopped asset watcher | 已停止资产监视器');
} catch (error) {
logger.warn('Failed to stop asset watcher | 停止资产监视器失败:', error);
}
}
}
/**
* Unload current project
* 卸载当前项目
*/
async unloadProject(): Promise<void> {
// Unsubscribe from file change events
// 取消订阅文件变化事件
await this._unsubscribeFromFileChanges();
this._projectPath = null;
this._manifest = null;
this._database.clear();
logger.info('Project assets unloaded');
}
/**
* Load manifest from project
*/
private async _loadManifest(): Promise<void> {
if (!this._fileSystem || !this._projectPath) return;
const manifestPath = this._getManifestPath();
try {
const exists = await this._fileSystem.exists(manifestPath);
if (exists) {
const content = await this._fileSystem.readFile(manifestPath);
this._manifest = JSON.parse(content);
logger.debug('Loaded existing asset manifest');
} else {
this._manifest = this._createEmptyManifest();
logger.debug('Created new asset manifest');
}
} catch (error) {
logger.warn('Failed to load manifest, creating new one:', error);
this._manifest = this._createEmptyManifest();
}
}
/**
* Save manifest to project
*/
private async _saveManifest(): Promise<void> {
if (!this._fileSystem || !this._projectPath || !this._manifest) return;
const manifestPath = this._getManifestPath();
this._manifest.updatedAt = Date.now();
try {
const content = JSON.stringify(this._manifest, null, 2);
await this._fileSystem.writeFile(manifestPath, content);
logger.debug('Saved asset manifest');
} catch (error) {
logger.error('Failed to save manifest:', error);
}
}
/**
* Get manifest file path
*/
private _getManifestPath(): string {
const sep = this._projectPath!.includes('\\') ? '\\' : '/';
return `${this._projectPath}${sep}${AssetRegistryService.MANIFEST_FILE}`;
}
/**
* Create empty manifest
*/
private _createEmptyManifest(): AssetManifest {
return {
version: AssetRegistryService.MANIFEST_VERSION,
createdAt: Date.now(),
updatedAt: Date.now(),
assets: {}
};
}
/**
* Scan all project directories for assets
* 扫描项目中所有目录的资产
*/
private async _scanAssetsDirectory(): Promise<void> {
if (!this._fileSystem || !this._projectPath) return;
const sep = this._projectPath.includes('\\') ? '\\' : '/';
// 扫描多个目录assets, scripts, scenes, ecs-scenes
// Scan multiple directories: assets, scripts, scenes, ecs-scenes
const directoriesToScan = MANAGED_ASSET_DIRECTORIES.map(name => ({
path: `${this._projectPath}${sep}${name}`,
name
}));
for (const dir of directoriesToScan) {
try {
const exists = await this._fileSystem.exists(dir.path);
if (!exists) continue;
await this._scanDirectory(dir.path, dir.name);
} catch (error) {
logger.error(`Failed to scan ${dir.name} directory:`, error);
}
}
}
/**
* Recursively scan a directory
* 递归扫描目录
*/
private async _scanDirectory(absolutePath: string, relativePath: string): Promise<void> {
if (!this._fileSystem) return;
try {
// 使用标准 IFileSystem.listDirectory
// Use standard IFileSystem.listDirectory
const entries: FileEntry[] = await this._fileSystem.listDirectory(absolutePath);
const sep = absolutePath.includes('\\') ? '\\' : '/';
for (const entry of entries) {
const entryAbsPath = entry.path || `${absolutePath}${sep}${entry.name}`;
const entryRelPath = `${relativePath}/${entry.name}`;
try {
if (entry.isDirectory) {
// Recursively scan subdirectory
await this._scanDirectory(entryAbsPath, entryRelPath);
} else {
// Register file as asset with size from entry
await this._registerAssetFile(entryAbsPath, entryRelPath, entry.size, entry.modified);
}
} catch (error) {
logger.warn(`Failed to process entry ${entry.name}:`, error);
}
}
} catch (error) {
logger.warn(`Failed to read directory ${absolutePath}:`, error);
}
}
/**
* Register a single asset file
* 注册单个资产文件
*
* @param absolutePath - 绝对路径 | Absolute path
* @param relativePath - 相对路径 | Relative path
* @param size - 文件大小(可选)| File size (optional)
* @param modified - 修改时间(可选)| Modified time (optional)
*/
private async _registerAssetFile(
absolutePath: string,
relativePath: string,
size?: number,
modified?: Date
): 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] || inferAssetType(relativePath);
// Skip unknown file types
if (!assetType || assetType === 'binary') return;
// Use provided size/modified or default values
const fileSize = size ?? 0;
const fileMtime = modified ? modified.getTime() : Date.now();
// Use MetaManager to get or create meta (with .meta file)
let meta: IAssetMeta;
try {
logger.debug(`Creating/loading meta for: ${relativePath}`);
meta = await this._metaManager.getOrCreateMeta(absolutePath);
logger.debug(`Meta created/loaded for ${relativePath}: guid=${meta.guid}`);
} catch (e) {
logger.warn(`Failed to get meta for ${relativePath}:`, e);
return;
}
const guid = meta.guid;
// Update manifest for backward compatibility
if (!this._manifest.assets[relativePath]) {
this._manifest.assets[relativePath] = {
guid,
relativePath,
type: assetType
};
}
// Get file name
const lastSlash = relativePath.lastIndexOf('/');
const fileName = lastSlash >= 0 ? relativePath.substring(lastSlash + 1) : relativePath;
const name = fileName.substring(0, fileName.lastIndexOf('.'));
// Create metadata
const metadata: IAssetRegistryMetadata = {
guid,
path: relativePath,
type: assetType,
name,
size: fileSize,
hash: '', // Could compute hash if needed
lastModified: fileMtime
};
// Register in database
this._database.addAsset(metadata);
}
/**
* Generate a unique GUID
*/
private _generateGUID(): AssetGUID {
// Simple UUID v4 generation
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
const r = (Math.random() * 16) | 0;
const v = c === 'x' ? r : (r & 0x3) | 0x8;
return v.toString(16);
});
}
// ==================== Public API ====================
/**
* Get asset metadata by GUID
*/
getAsset(guid: AssetGUID): IAssetRegistryMetadata | undefined {
return this._database.getMetadata(guid);
}
/**
* Get asset metadata by relative path
*/
getAssetByPath(relativePath: string): IAssetRegistryMetadata | undefined {
return this._database.getMetadataByPath(relativePath);
}
/**
* Get GUID for a relative path
*/
getGuidByPath(relativePath: string): AssetGUID | undefined {
const metadata = this._database.getMetadataByPath(relativePath);
if (!metadata) {
// Debug: show registered paths if not found
const stats = this._database.getStatistics();
logger.debug(`[AssetRegistry] GUID not found for path: "${relativePath}", total assets: ${stats.totalAssets}`);
}
return metadata?.guid;
}
/**
* Get relative path for a GUID
*/
getPathByGuid(guid: AssetGUID): string | undefined {
const metadata = this._database.getMetadata(guid);
return metadata?.path;
}
/**
* Convert absolute path to relative path
*/
absoluteToRelative(absolutePath: string): string | null {
if (!this._projectPath) return null;
const normalizedAbs = absolutePath.replace(/\\/g, '/');
const normalizedProject = this._projectPath.replace(/\\/g, '/');
if (normalizedAbs.startsWith(normalizedProject)) {
return normalizedAbs.substring(normalizedProject.length + 1);
}
return null;
}
/**
* Convert relative path to absolute path
*/
relativeToAbsolute(relativePath: string): string | null {
if (!this._projectPath) return null;
const sep = this._projectPath.includes('\\') ? '\\' : '/';
return `${this._projectPath}${sep}${relativePath.replace(/\//g, sep)}`;
}
/**
* Find assets by type
*/
findAssetsByType(type: AssetRegistryType): IAssetRegistryMetadata[] {
const guids = this._database.findAssetsByType(type);
return guids
.map(guid => this._database.getMetadata(guid))
.filter((m): m is IAssetRegistryMetadata => m !== undefined);
}
/**
* Get all registered assets
*/
getAllAssets(): IAssetRegistryMetadata[] {
const entries = this._database.exportToCatalog();
return entries.map(entry => this._database.getMetadata(entry.guid))
.filter((m): m is IAssetRegistryMetadata => m !== undefined);
}
/**
* Export catalog for runtime use
* 导出运行时使用的资产目录
*/
exportCatalog(): IAssetRegistryCatalogEntry[] {
return this._database.exportToCatalog();
}
/**
* Export catalog as JSON string
*/
exportCatalogJSON(): string {
const entries = this._database.exportToCatalog();
const catalog = {
version: '1.0.0',
createdAt: Date.now(),
entries: Object.fromEntries(entries.map(e => [e.guid, e]))
};
return JSON.stringify(catalog, null, 2);
}
/**
* Register a new asset (e.g., when a file is created)
*/
async registerAsset(absolutePath: string): Promise<AssetGUID | null> {
const relativePath = this.absoluteToRelative(absolutePath);
if (!relativePath) return null;
await this._registerAssetFile(absolutePath, relativePath);
await this._saveManifest();
const metadata = this._database.getMetadataByPath(relativePath);
// Publish event to notify ContentBrowser and other listeners
// 发布事件通知 ContentBrowser 和其他监听者
if (metadata) {
this._messageHub?.publish('assets:changed', {
type: 'add',
path: absolutePath,
relativePath,
guid: metadata.guid
});
}
return metadata?.guid ?? null;
}
/**
* Unregister an asset (e.g., when a file is deleted)
*/
async unregisterAsset(absolutePath: string): Promise<void> {
const relativePath = this.absoluteToRelative(absolutePath);
if (!relativePath || !this._manifest) return;
const metadata = this._database.getMetadataByPath(relativePath);
if (metadata) {
const guid = metadata.guid;
this._database.removeAsset(guid);
delete this._manifest.assets[relativePath];
await this._saveManifest();
// Publish event to notify ContentBrowser and other listeners
// 发布事件通知 ContentBrowser 和其他监听者
this._messageHub?.publish('assets:changed', {
type: 'remove',
path: absolutePath,
relativePath,
guid
});
}
}
/**
* Refresh a single asset (e.g., when file is modified)
*/
async refreshAsset(absolutePath: string): Promise<void> {
const relativePath = this.absoluteToRelative(absolutePath);
if (!relativePath) return;
// Re-register the asset
await this._registerAssetFile(absolutePath, relativePath);
await this._saveManifest();
const metadata = this._database.getMetadataByPath(relativePath);
if (metadata) {
// Publish event to notify ContentBrowser and other listeners
// 发布事件通知 ContentBrowser 和其他监听者
this._messageHub?.publish('assets:changed', {
type: 'modify',
path: absolutePath,
relativePath,
guid: metadata.guid
});
}
}
/**
* Get database statistics
*/
getStatistics() {
return this._database.getStatistics();
}
/**
* Check if service is ready
*/
get isReady(): boolean {
return this._initialized && this._projectPath !== null;
}
/**
* Get current project path
*/
get projectPath(): string | null {
return this._projectPath;
}
/**
* Get managed asset directories
* 获取被管理的资产目录
*/
getManagedDirectories(): readonly string[] {
return MANAGED_ASSET_DIRECTORIES;
}
/**
* Check if a path is within a managed directory
* 检查路径是否在被管理的目录中
*
* @param pathToCheck - Absolute or relative path | 绝对或相对路径
* @returns Whether the path is in a managed directory | 路径是否在被管理的目录中
*/
isPathManaged(pathToCheck: string): boolean {
if (!pathToCheck) return false;
// Normalize path
const normalizedPath = pathToCheck.replace(/\\/g, '/');
// Check if path starts with any managed directory
for (const dir of MANAGED_ASSET_DIRECTORIES) {
// Check relative path (e.g., "assets/textures/...")
if (normalizedPath.startsWith(`${dir}/`) || normalizedPath === dir) {
return true;
}
// Check absolute path (e.g., "C:/project/assets/...")
if (this._projectPath) {
const normalizedProject = this._projectPath.replace(/\\/g, '/');
const managedAbsPath = `${normalizedProject}/${dir}`;
if (normalizedPath.startsWith(`${managedAbsPath}/`) || normalizedPath === managedAbsPath) {
return true;
}
}
}
return false;
}
/**
* Get the managed directory name for a path (if any)
* 获取路径所属的被管理目录名称(如果有)
*
* @param pathToCheck - Absolute or relative path | 绝对或相对路径
* @returns The managed directory name or null | 被管理的目录名称或 null
*/
getManagedDirectoryForPath(pathToCheck: string): ManagedAssetDirectory | null {
if (!pathToCheck) return null;
const normalizedPath = pathToCheck.replace(/\\/g, '/');
for (const dir of MANAGED_ASSET_DIRECTORIES) {
if (normalizedPath.startsWith(`${dir}/`) || normalizedPath === dir) {
return dir;
}
if (this._projectPath) {
const normalizedProject = this._projectPath.replace(/\\/g, '/');
const managedAbsPath = `${normalizedProject}/${dir}`;
if (normalizedPath.startsWith(`${managedAbsPath}/`) || normalizedPath === managedAbsPath) {
return dir;
}
}
}
return null;
}
/**
* Dispose the service
* 销毁服务
*/
dispose(): void {
// Fire and forget async cleanup | 异步清理(不等待)
void this._unsubscribeFromFileChanges();
void this.unloadProject();
this._initialized = false;
}
}

View File

@@ -0,0 +1,25 @@
import { ICommand } from './ICommand';
/**
* 命令基类
* 提供默认实现,具体命令继承此类
*/
export abstract class BaseCommand implements ICommand {
abstract execute(): void;
abstract undo(): void;
abstract getDescription(): string;
/**
* 默认不支持合并
*/
canMergeWith(_other: ICommand): boolean {
return false;
}
/**
* 默认抛出错误
*/
mergeWith(_other: ICommand): ICommand {
throw new Error(`${this.constructor.name} 不支持合并操作`);
}
}

View File

@@ -0,0 +1,301 @@
/**
* @zh 通用注册表基类
* @en Generic Registry Base Class
*
* @zh 提供注册表的通用实现,消除 16+ 个 Registry 类中的重复代码。
* @en Provides common registry implementation, eliminating duplicate code in 16+ Registry classes.
*
* @example
* ```typescript
* interface IMyItem { id: string; name: string; }
*
* class MyRegistry extends BaseRegistry<IMyItem> {
* constructor() {
* super('MyRegistry');
* }
*
* protected getItemId(item: IMyItem): string {
* return item.id;
* }
* }
* ```
*/
import { IService, createLogger, type ILogger } from '@esengine/ecs-framework';
/**
* @zh 可注册项的基础接口
* @en Base interface for registrable items
*/
export interface IRegistrable {
/** @zh 唯一标识符 @en Unique identifier */
readonly id?: string;
}
/**
* @zh 带优先级的可注册项
* @en Registrable item with priority
*/
export interface IPrioritized {
/** @zh 优先级(越高越先匹配) @en Priority (higher = matched first) */
readonly priority?: number;
}
/**
* @zh 带顺序的可注册项
* @en Registrable item with order
*/
export interface IOrdered {
/** @zh 排序权重(越小越靠前) @en Sort order (lower = first) */
readonly order?: number;
}
/**
* @zh 注册表配置
* @en Registry configuration
*/
export interface RegistryOptions {
/** @zh 是否允许覆盖已存在的项 @en Allow overwriting existing items */
allowOverwrite?: boolean;
/** @zh 是否在覆盖时发出警告 @en Warn when overwriting */
warnOnOverwrite?: boolean;
}
/**
* @zh 通用注册表基类
* @en Generic Registry Base Class
*
* @typeParam T - @zh 注册项类型 @en Registered item type
* @typeParam K - @zh 键类型(默认 string @en Key type (default string)
*/
export abstract class BaseRegistry<T, K extends string = string> implements IService {
protected readonly _items: Map<K, T> = new Map();
protected readonly _logger: ILogger;
protected readonly _options: Required<RegistryOptions>;
constructor(
protected readonly _name: string,
options: RegistryOptions = {}
) {
this._logger = createLogger(_name);
this._options = {
allowOverwrite: options.allowOverwrite ?? true,
warnOnOverwrite: options.warnOnOverwrite ?? true
};
}
/**
* @zh 获取项的键
* @en Get item key
*/
protected abstract getItemKey(item: T): K;
/**
* @zh 获取项的显示名称(用于日志)
* @en Get item display name (for logging)
*/
protected getItemDisplayName(item: T): string {
const key = this.getItemKey(item);
return String(key);
}
// ========== 核心 CRUD 操作 | Core CRUD Operations ==========
/**
* @zh 注册项
* @en Register item
*/
register(item: T): boolean {
const key = this.getItemKey(item);
const displayName = this.getItemDisplayName(item);
if (this._items.has(key)) {
if (this._options.warnOnOverwrite) {
this._logger.warn(`Overwriting: ${displayName}`);
}
if (!this._options.allowOverwrite) {
return false;
}
}
this._items.set(key, item);
this._logger.debug(`Registered: ${displayName}`);
return true;
}
/**
* @zh 批量注册
* @en Register multiple items
*/
registerMany(items: T[]): number {
let count = 0;
for (const item of items) {
if (this.register(item)) count++;
}
return count;
}
/**
* @zh 注销项
* @en Unregister item
*/
unregister(key: K): boolean {
if (this._items.delete(key)) {
this._logger.debug(`Unregistered: ${key}`);
return true;
}
return false;
}
/**
* @zh 获取项
* @en Get item
*/
get(key: K): T | undefined {
return this._items.get(key);
}
/**
* @zh 检查是否存在
* @en Check if exists
*/
has(key: K): boolean {
return this._items.has(key);
}
// ========== 查询操作 | Query Operations ==========
/**
* @zh 获取所有项
* @en Get all items
*/
getAll(): T[] {
return Array.from(this._items.values());
}
/**
* @zh 获取所有键
* @en Get all keys
*/
getAllKeys(): K[] {
return Array.from(this._items.keys());
}
/**
* @zh 获取项数量
* @en Get item count
*/
get size(): number {
return this._items.size;
}
/**
* @zh 是否为空
* @en Is empty
*/
get isEmpty(): boolean {
return this._items.size === 0;
}
/**
* @zh 按条件过滤
* @en Filter by predicate
*/
filter(predicate: (item: T, key: K) => boolean): T[] {
const result: T[] = [];
for (const [key, item] of this._items) {
if (predicate(item, key)) {
result.push(item);
}
}
return result;
}
/**
* @zh 查找第一个匹配项
* @en Find first matching item
*/
find(predicate: (item: T, key: K) => boolean): T | undefined {
for (const [key, item] of this._items) {
if (predicate(item, key)) {
return item;
}
}
return undefined;
}
// ========== 排序操作 | Sorting Operations ==========
/**
* @zh 按 order 字段排序(越小越靠前)
* @en Sort items by order field (lower = first)
*/
protected sortByOrder<U extends IOrdered>(items: U[], defaultOrder = 0): U[] {
return items.sort((a, b) => (a.order ?? defaultOrder) - (b.order ?? defaultOrder));
}
// ========== 生命周期 | Lifecycle ==========
/**
* @zh 清空注册表
* @en Clear registry
*/
clear(): void {
this._items.clear();
this._logger.debug('Cleared');
}
/**
* @zh 释放资源
* @en Dispose resources
*/
dispose(): void {
this.clear();
this._logger.debug('Disposed');
}
}
/**
* @zh 带优先级查找的注册表基类
* @en Registry base class with priority-based lookup
*
* @zh 适用于需要按优先级匹配的场景(如 Inspector、FieldEditor
* @en For scenarios requiring priority-based matching (e.g., Inspector, FieldEditor)
*/
export abstract class PrioritizedRegistry<T extends IPrioritized, K extends string = string>
extends BaseRegistry<T, K> {
/**
* @zh 按优先级排序获取所有项
* @en Get all items sorted by priority
*/
getAllSorted(): T[] {
return this.getAll().sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0));
}
/**
* @zh 查找第一个能处理目标的项
* @en Find first item that can handle the target
*
* @param canHandle - @zh 判断函数 @en Predicate function
*/
findByPriority(canHandle: (item: T) => boolean): T | undefined {
for (const item of this.getAllSorted()) {
if (canHandle(item)) {
return item;
}
}
return undefined;
}
}
/**
* @zh 创建注册表服务标识符
* @en Create registry service identifier
*
* @zh 使用 Symbol.for 确保跨包共享同一个 Symbol
* @en Uses Symbol.for to ensure same Symbol is shared across packages
*/
export function createRegistryToken<T>(name: string): symbol {
return Symbol.for(`IRegistry:${name}`);
}

View File

@@ -0,0 +1,312 @@
/**
* Build Service.
* 构建服务。
*
* Manages build pipelines and executes build tasks.
* 管理构建管线和执行构建任务。
*/
import type { IService } from '@esengine/ecs-framework';
import { createLogger } from '@esengine/ecs-framework';
import type {
IBuildPipeline,
IBuildPipelineRegistry,
BuildPlatform,
BuildConfig,
BuildResult,
BuildProgress
} from './IBuildPipeline';
import { BuildStatus } from './IBuildPipeline';
const logger = createLogger('BuildService');
/**
* 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)) {
logger.warn(`Overwriting existing pipeline: ${pipeline.platform} | 覆盖已存在的构建管线: ${pipeline.platform}`);
}
this._pipelines.set(pipeline.platform, pipeline);
logger.info(`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;
logger.info('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,441 @@
/**
* 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 build mode.
* Web 构建模式。
*/
export type WebBuildMode =
/** Split bundles: Core + plugins loaded on demand, best for production games
* 分包模式:核心包 + 插件按需加载,适合正式游戏 */
| 'split-bundles'
/** Single bundle: All code in one JS file, suitable for simple deployment
* 单包模式:所有代码打包到一个 JS 文件,适合简单部署 */
| 'single-bundle'
/** Single file: Everything inlined into one HTML file, best for playable ads
* 单文件模式:所有内容内联到一个 HTML 文件,适合可玩广告 */
| 'single-file';
/**
* Inline configuration for single-file builds.
* 单文件构建的内联配置。
*
* Single-file mode inlines EVERYTHING into one HTML file by default.
* These options allow disabling specific inlining for debugging purposes.
* 单文件模式默认将所有内容内联到一个 HTML 文件中。
* 这些选项允许为调试目的禁用特定的内联。
*/
export interface InlineConfig {
/**
* Inline JavaScript into HTML as <script> tag content.
* 将 JS 内联到 HTML 的 <script> 标签中。
* Default: true
*/
inlineJs?: boolean;
/**
* Inline WASM files as Base64.
* 将 WASM 文件转为 Base64 内联。
* Default: true
*/
inlineWasm?: boolean;
/**
* Inline asset files (images, audio, fonts) as Base64 data URLs.
* 将资产文件(图片、音频、字体)转为 Base64 data URL 内联。
* Default: true
*/
inlineAssets?: boolean;
/**
* Inline scene JSON files.
* 内联场景 JSON 文件。
* Default: true
*/
inlineScenes?: boolean;
}
/**
* Web platform build configuration.
* Web 平台构建配置。
*/
export interface WebBuildConfig extends BuildConfig {
platform: BuildPlatform.Web;
/**
* Build mode.
* 构建模式。
* - 'split-bundles': Core + plugins loaded on demand, best for production (default)
* - 'single-bundle': All code in one JS file, suitable for simple deployment
* - 'single-file': Everything inlined into one HTML, best for playable ads
*/
buildMode: WebBuildMode;
/**
* Inline configuration for single-file builds.
* 单文件构建的内联配置。
* Only used when buildMode is 'single-file'.
*/
inlineConfig?: InlineConfig;
/**
* Whether to minify output.
* 是否压缩输出。
* Default: true for release builds
*/
minify?: boolean;
/**
* Whether to generate HTML file.
* 是否生成 HTML 文件。
*/
generateHtml: boolean;
/**
* HTML template path.
* HTML 模板路径。
*/
htmlTemplate?: string;
/**
* Asset loading strategy.
* 资产加载策略。
* - 'preload': Load all assets before game starts (best for small games)
* - 'on-demand': Load assets when needed (best for large games)
* Default: 'on-demand'
*/
assetLoadingStrategy?: 'preload' | 'on-demand';
/**
* Whether to generate asset catalog.
* 是否生成资产清单。
* Default: true
*/
generateAssetCatalog?: boolean;
/**
* Asset file extensions to copy (glob patterns).
* 要复制的资产文件扩展名glob 模式)。
*
* If not provided, uses default extensions.
* If provided by plugins via AssetLoaderFactory.getAllSupportedExtensions(),
* includes all registered loader extensions.
*
* 如果未提供,使用默认扩展名。
* 如果通过 AssetLoaderFactory.getAllSupportedExtensions() 由插件提供,
* 则包含所有已注册加载器的扩展名。
*
* @example ['*.png', '*.jpg', '*.particle', '*.bt']
*/
assetExtensions?: string[];
/**
* Asset extension to type mapping.
* 资产扩展名到类型的映射。
*
* Used by asset catalog generation to determine asset types.
* If not provided, uses default mapping.
*
* 用于资产目录生成以确定资产类型。
* 如果未提供,使用默认映射。
*
* @example { 'png': 'texture', 'particle': 'particle' }
*/
assetTypeMap?: Record<string, string>;
}
/**
* 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,28 @@
/**
* Build System.
* 构建系统。
*
* Provides cross-platform project build capabilities.
* 提供跨平台的项目构建能力。
*/
export {
BuildPlatform,
BuildStatus,
type BuildProgress,
type BuildConfig,
type WebBuildConfig,
type WebBuildMode,
type InlineConfig,
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

@@ -0,0 +1,210 @@
import { ICommand } from './ICommand';
/**
* @zh 命令历史记录配置
* @en Command history configuration
*/
export interface CommandManagerConfig {
/** @zh 最大历史记录数量 @en Maximum history size */
maxHistorySize?: number;
/** @zh 是否自动合并相似命令 @en Auto merge similar commands */
autoMerge?: boolean;
}
/**
* @zh 命令管理器 - 管理命令的执行、撤销、重做以及历史记录
* @en Command Manager - Manages command execution, undo, redo and history
*/
export class CommandManager {
private _undoStack: ICommand[] = [];
private _redoStack: ICommand[] = [];
private readonly _config: Required<CommandManagerConfig>;
private _isExecuting = false;
constructor(config: CommandManagerConfig = {}) {
this._config = {
maxHistorySize: config.maxHistorySize ?? 100,
autoMerge: config.autoMerge ?? true
};
}
/**
* @zh 尝试将命令与栈顶命令合并
* @en Try to merge command with the top of stack
*/
private _tryMergeWithLast(command: ICommand): boolean {
if (!this._config.autoMerge || this._undoStack.length === 0) {
return false;
}
const lastCommand = this._undoStack[this._undoStack.length - 1];
if (lastCommand?.canMergeWith(command)) {
this._undoStack[this._undoStack.length - 1] = lastCommand.mergeWith(command);
this._redoStack = [];
return true;
}
return false;
}
/**
* @zh 将命令推入撤销栈
* @en Push command to undo stack
*/
private _pushToUndoStack(command: ICommand): void {
if (this._tryMergeWithLast(command)) {
return;
}
this._undoStack.push(command);
this._redoStack = [];
if (this._undoStack.length > this._config.maxHistorySize) {
this._undoStack.shift();
}
}
/**
* @zh 执行命令
* @en Execute command
*/
execute(command: ICommand): void {
if (this._isExecuting) {
throw new Error('Cannot execute command while another is executing');
}
this._isExecuting = true;
try {
command.execute();
this._pushToUndoStack(command);
} finally {
this._isExecuting = false;
}
}
/**
* @zh 撤销上一个命令
* @en Undo last command
*/
undo(): void {
if (this._isExecuting) {
throw new Error('Cannot undo while executing');
}
const command = this._undoStack.pop();
if (!command) return;
this._isExecuting = true;
try {
command.undo();
this._redoStack.push(command);
} catch (error) {
this._undoStack.push(command);
throw error;
} finally {
this._isExecuting = false;
}
}
/**
* @zh 重做上一个被撤销的命令
* @en Redo last undone command
*/
redo(): void {
if (this._isExecuting) {
throw new Error('Cannot redo while executing');
}
const command = this._redoStack.pop();
if (!command) return;
this._isExecuting = true;
try {
command.execute();
this._undoStack.push(command);
} catch (error) {
this._redoStack.push(command);
throw error;
} finally {
this._isExecuting = false;
}
}
/** @zh 检查是否可以撤销 @en Check if can undo */
canUndo(): boolean {
return this._undoStack.length > 0;
}
/** @zh 检查是否可以重做 @en Check if can redo */
canRedo(): boolean {
return this._redoStack.length > 0;
}
/** @zh 获取撤销栈的描述列表 @en Get undo history descriptions */
getUndoHistory(): string[] {
return this._undoStack.map(cmd => cmd.getDescription());
}
/** @zh 获取重做栈的描述列表 @en Get redo history descriptions */
getRedoHistory(): string[] {
return this._redoStack.map(cmd => cmd.getDescription());
}
/** @zh 清空所有历史记录 @en Clear all history */
clear(): void {
this._undoStack = [];
this._redoStack = [];
}
/**
* @zh 批量执行命令(作为单一操作,可以一次撤销)
* @en Execute batch commands (as single operation, can be undone at once)
*/
executeBatch(commands: ICommand[]): void {
if (commands.length === 0) return;
this.execute(new BatchCommand(commands));
}
/**
* @zh 将命令推入撤销栈但不执行(用于已执行的操作如拖动变换)
* @en Push command to undo stack without executing (for already performed operations)
*/
pushWithoutExecute(command: ICommand): void {
this._pushToUndoStack(command);
}
}
/**
* @zh 批量命令 - 将多个命令组合为一个命令
* @en Batch Command - Combines multiple commands into one
*/
class BatchCommand implements ICommand {
constructor(private readonly commands: ICommand[]) {}
execute(): void {
for (const command of this.commands) {
command.execute();
}
}
undo(): void {
for (let i = this.commands.length - 1; i >= 0; i--) {
this.commands[i]?.undo();
}
}
getDescription(): string {
return `Batch (${this.commands.length} commands)`;
}
canMergeWith(): boolean {
return false;
}
mergeWith(): ICommand {
throw new Error('Batch commands cannot be merged');
}
}

View File

@@ -0,0 +1,28 @@
/**
* @zh 编译器注册表
* @en Compiler Registry
*/
import { BaseRegistry, createRegistryToken } from './BaseRegistry';
import type { ICompiler } from './ICompiler';
/**
* @zh 编译器注册表
* @en Compiler Registry
*/
export class CompilerRegistry extends BaseRegistry<ICompiler> {
constructor() {
super('CompilerRegistry');
}
protected getItemKey(item: ICompiler): string {
return item.id;
}
protected override getItemDisplayName(item: ICompiler): string {
return `${item.name} (${item.id})`;
}
}
/** @zh 编译器注册表服务标识符 @en Compiler registry service identifier */
export const ICompilerRegistry = createRegistryToken<CompilerRegistry>('CompilerRegistry');

View File

@@ -0,0 +1,112 @@
/**
* @zh 组件动作注册表服务
* @en Component Action Registry Service
*
* @zh 管理检视器面板中的组件特定动作
* @en Manages component-specific actions for the inspector panel
*/
import { createLogger, type ILogger, type IService } from '@esengine/ecs-framework';
import type { ComponentAction } from '../Plugin/EditorModule';
import { createRegistryToken } from './BaseRegistry';
export type { ComponentAction } from '../Plugin/EditorModule';
/**
* @zh 组件动作注册表
* @en Component Action Registry
*/
export class ComponentActionRegistry implements IService {
private readonly _actions = new Map<string, ComponentAction[]>();
private readonly _logger: ILogger;
constructor() {
this._logger = createLogger('ComponentActionRegistry');
}
/**
* @zh 注册组件动作
* @en Register component action
*/
register(action: ComponentAction): void {
const { componentName, id } = action;
if (!this._actions.has(componentName)) {
this._actions.set(componentName, []);
}
const actions = this._actions.get(componentName)!;
const existingIndex = actions.findIndex(a => a.id === id);
if (existingIndex >= 0) {
this._logger.warn(`Overwriting action: ${id} for ${componentName}`);
actions[existingIndex] = action;
} else {
actions.push(action);
this._logger.debug(`Registered action: ${id} for ${componentName}`);
}
}
/**
* @zh 批量注册动作
* @en Register multiple actions
*/
registerMany(actions: ComponentAction[]): void {
for (const action of actions) {
this.register(action);
}
}
/**
* @zh 注销动作
* @en Unregister action
*/
unregister(componentName: string, actionId: string): boolean {
const actions = this._actions.get(componentName);
if (!actions) return false;
const index = actions.findIndex(a => a.id === actionId);
if (index < 0) return false;
actions.splice(index, 1);
this._logger.debug(`Unregistered action: ${actionId} from ${componentName}`);
if (actions.length === 0) {
this._actions.delete(componentName);
}
return true;
}
/**
* @zh 获取组件的所有动作(按 order 排序)
* @en Get all actions for component (sorted by order)
*/
getActionsForComponent(componentName: string): ComponentAction[] {
const actions = this._actions.get(componentName);
if (!actions) return [];
return [...actions].sort((a, b) => (a.order ?? 100) - (b.order ?? 100));
}
/**
* @zh 检查组件是否有动作
* @en Check if component has actions
*/
hasActions(componentName: string): boolean {
const actions = this._actions.get(componentName);
return actions !== undefined && actions.length > 0;
}
/** @zh 清空所有动作 @en Clear all actions */
clear(): void {
this._actions.clear();
this._logger.debug('Cleared');
}
/** @zh 释放资源 @en Dispose resources */
dispose(): void {
this.clear();
}
}
/** @zh 组件动作注册表服务标识符 @en Component action registry service identifier */
export const IComponentActionRegistry = createRegistryToken<ComponentActionRegistry>('ComponentActionRegistry');

View File

@@ -0,0 +1,102 @@
import type { IService } from '@esengine/ecs-framework';
import { Injectable } from '@esengine/ecs-framework';
import { createLogger } from '@esengine/ecs-framework';
import { MessageHub } from './MessageHub';
const logger = createLogger('ComponentDiscoveryService');
export interface ComponentFileInfo {
path: string;
fileName: string;
className: string | null;
}
export interface ComponentScanOptions {
basePath: string;
pattern: string;
scanFunction: (path: string, pattern: string) => Promise<string[]>;
readFunction: (path: string) => Promise<string>;
}
@Injectable()
export class ComponentDiscoveryService implements IService {
private discoveredComponents: Map<string, ComponentFileInfo> = new Map();
private messageHub: MessageHub;
constructor(messageHub: MessageHub) {
this.messageHub = messageHub;
}
public async scanComponents(options: ComponentScanOptions): Promise<ComponentFileInfo[]> {
try {
logger.info('Scanning for components', {
basePath: options.basePath,
pattern: options.pattern
});
const files = await options.scanFunction(options.basePath, options.pattern);
logger.info(`Found ${files.length} component files`);
const componentInfos: ComponentFileInfo[] = [];
for (const filePath of files) {
try {
const fileContent = await options.readFunction(filePath);
const componentInfo = this.parseComponentFile(filePath, fileContent);
if (componentInfo) {
componentInfos.push(componentInfo);
this.discoveredComponents.set(filePath, componentInfo);
}
} catch (error) {
logger.warn(`Failed to parse component file: ${filePath}`, error);
}
}
await this.messageHub.publish('components:discovered', {
count: componentInfos.length,
components: componentInfos
});
logger.info(`Successfully parsed ${componentInfos.length} components`);
return componentInfos;
} catch (error) {
logger.error('Failed to scan components', error);
throw error;
}
}
public getDiscoveredComponents(): ComponentFileInfo[] {
return Array.from(this.discoveredComponents.values());
}
public clearDiscoveredComponents(): void {
this.discoveredComponents.clear();
logger.info('Cleared discovered components');
}
private parseComponentFile(filePath: string, content: string): ComponentFileInfo | null {
const fileName = filePath.split(/[\\/]/).pop() || '';
const classMatch = content.match(/export\s+class\s+(\w+)\s+extends\s+Component/);
if (classMatch) {
const className = classMatch[1];
logger.debug(`Found component class: ${className} in ${fileName}`);
return {
path: filePath,
fileName,
className
};
}
logger.debug(`No valid component class found in ${fileName}`);
return null;
}
public dispose(): void {
this.discoveredComponents.clear();
logger.info('ComponentDiscoveryService disposed');
}
}

View File

@@ -0,0 +1,176 @@
import React from 'react';
import { Component } from '@esengine/ecs-framework';
import { PrioritizedRegistry, createRegistryToken, type IPrioritized } from './BaseRegistry';
/**
* @zh 组件检查器上下文
* @en Context passed to component inspectors
*/
export interface ComponentInspectorContext {
/** @zh 被检查的组件 @en The component being inspected */
component: Component;
/** @zh 所属实体 @en Owner entity */
entity: any;
/** @zh 版本号(用于触发重渲染) @en Version (for triggering re-renders) */
version?: number;
/** @zh 属性变更回调 @en Property change callback */
onChange?: (propertyName: string, value: any) => void;
/** @zh 动作回调 @en Action callback */
onAction?: (actionId: string, propertyName: string, component: Component) => void;
}
/**
* @zh 检查器渲染模式
* @en Inspector render mode
*/
export type InspectorRenderMode = 'replace' | 'append';
/**
* @zh 组件检查器接口
* @en Interface for custom component inspectors
*/
export interface IComponentInspector<T extends Component = Component> extends IPrioritized {
/** @zh 唯一标识符 @en Unique identifier */
readonly id: string;
/** @zh 显示名称 @en Display name */
readonly name: string;
/** @zh 目标组件类型名称列表 @en Target component type names */
readonly targetComponents: string[];
/**
* @zh 渲染模式:'replace' 替换默认检查器,'append' 追加到默认检查器后
* @en Render mode: 'replace' replaces default, 'append' appends after default
*/
readonly renderMode?: InspectorRenderMode;
/** @zh 判断是否可以处理该组件 @en Check if can handle the component */
canHandle(component: Component): component is T;
/** @zh 渲染组件检查器 @en Render component inspector */
render(context: ComponentInspectorContext): React.ReactElement;
}
/**
* @zh 组件检查器注册表
* @en Registry for custom component inspectors
*/
export class ComponentInspectorRegistry extends PrioritizedRegistry<IComponentInspector> {
constructor() {
super('ComponentInspectorRegistry');
}
protected getItemKey(item: IComponentInspector): string {
return item.id;
}
protected override getItemDisplayName(item: IComponentInspector): string {
return `${item.name} (${item.id})`;
}
/**
* @zh 查找可以处理指定组件的检查器(仅 replace 模式)
* @en Find inspector that can handle the component (replace mode only)
*/
findInspector(component: Component): IComponentInspector | undefined {
const sorted = this.getAllSorted().filter(i => i.renderMode !== 'append');
for (const inspector of sorted) {
try {
if (inspector.canHandle(component)) {
return inspector;
}
} catch (error) {
this._logger.error(`Error in canHandle for ${inspector.id}:`, error);
}
}
return undefined;
}
/**
* @zh 查找所有追加模式的检查器
* @en Find all append-mode inspectors for the component
*/
findAppendInspectors(component: Component): IComponentInspector[] {
const sorted = this.getAllSorted().filter(i => i.renderMode === 'append');
const result: IComponentInspector[] = [];
for (const inspector of sorted) {
try {
if (inspector.canHandle(component)) {
result.push(inspector);
}
} catch (error) {
this._logger.error(`Error in canHandle for ${inspector.id}:`, error);
}
}
return result;
}
/**
* @zh 检查是否有自定义检查器replace 模式)
* @en Check if has custom inspector (replace mode)
*/
hasInspector(component: Component): boolean {
return this.findInspector(component) !== undefined;
}
/**
* @zh 检查是否有追加检查器
* @en Check if has append inspectors
*/
hasAppendInspectors(component: Component): boolean {
return this.findAppendInspectors(component).length > 0;
}
/**
* @zh 渲染组件replace 模式)
* @en Render component with replace-mode inspector
*/
render(context: ComponentInspectorContext): React.ReactElement | null {
const inspector = this.findInspector(context.component);
if (!inspector) return null;
try {
return inspector.render(context);
} catch (error) {
this._logger.error(`Error rendering with ${inspector.id}:`, error);
return React.createElement(
'span',
{ style: { color: '#f87171', fontStyle: 'italic' } },
'[Inspector Render Error]'
);
}
}
/**
* @zh 渲染追加检查器
* @en Render append-mode inspectors
*/
renderAppendInspectors(context: ComponentInspectorContext): React.ReactElement[] {
const inspectors = this.findAppendInspectors(context.component);
return inspectors.map(inspector => {
try {
return React.createElement(
React.Fragment,
{ key: inspector.id },
inspector.render(context)
);
} catch (error) {
this._logger.error(`Error rendering ${inspector.id}:`, error);
return React.createElement(
'span',
{ key: inspector.id, style: { color: '#f87171', fontStyle: 'italic' } },
`[${inspector.name} Error]`
);
}
});
}
/**
* @zh 获取所有注册的检查器
* @en Get all registered inspectors
*/
getAllInspectors(): IComponentInspector[] {
return this.getAll();
}
}
/** @zh 组件检查器注册表服务标识符 @en Component inspector registry service identifier */
export const IComponentInspectorRegistry = createRegistryToken<ComponentInspectorRegistry>('ComponentInspectorRegistry');

View File

@@ -0,0 +1,85 @@
import { Component } from '@esengine/ecs-framework';
import { BaseRegistry, createRegistryToken } from './BaseRegistry';
/**
* @zh 组件类型信息
* @en Component type info
*/
export interface ComponentTypeInfo {
/** @zh 组件名称 @en Component name */
name: string;
/** @zh 组件类型 @en Component type */
type?: new (...args: any[]) => Component;
/** @zh 分类 @en Category */
category?: string;
/** @zh 描述 @en Description */
description?: string;
/** @zh 图标 @en Icon */
icon?: string;
/** @zh 元数据 @en Metadata */
metadata?: {
path?: string;
fileName?: string;
[key: string]: any;
};
}
/**
* @zh 编辑器组件注册表
* @en Editor Component Registry
*
* @zh 管理编辑器中可用的组件类型元数据(名称、分类、图标等)。
* 与 ECS 核心的 ComponentRegistry管理组件位掩码不同。
* @en Manages component type metadata (name, category, icon, etc.) for the editor.
* Different from the ECS core ComponentRegistry (which manages component bitmasks).
*/
export class EditorComponentRegistry extends BaseRegistry<ComponentTypeInfo> {
constructor() {
super('EditorComponentRegistry');
}
protected getItemKey(item: ComponentTypeInfo): string {
return item.name;
}
protected override getItemDisplayName(item: ComponentTypeInfo): string {
return `${item.name}${item.category ? ` [${item.category}]` : ''}`;
}
/**
* @zh 获取组件信息
* @en Get component info
*/
getComponent(name: string): ComponentTypeInfo | undefined {
return this.get(name);
}
/**
* @zh 获取所有组件
* @en Get all components
*/
getAllComponents(): ComponentTypeInfo[] {
return this.getAll();
}
/**
* @zh 按分类获取组件
* @en Get components by category
*/
getComponentsByCategory(category: string): ComponentTypeInfo[] {
return this.filter(c => c.category === category);
}
/**
* @zh 创建组件实例
* @en Create component instance
*/
createInstance(name: string, ...args: any[]): Component | null {
const info = this.get(name);
if (!info || !info.type) return null;
return new info.type(...args);
}
}
/** @zh 编辑器组件注册表服务标识符 @en Editor component registry service identifier */
export const IEditorComponentRegistry = createRegistryToken<EditorComponentRegistry>('EditorComponentRegistry');

View File

@@ -0,0 +1,405 @@
/**
* Editor Viewport Service
* 编辑器视口服务
*
* Manages editor viewports with preview scene support and overlay rendering.
* 管理带有预览场景支持和覆盖层渲染的编辑器视口。
*/
import { createLogger } from '@esengine/ecs-framework';
import type { IViewportService, ViewportCameraConfig } from './IViewportService';
import type { IPreviewScene } from './PreviewSceneService';
import { PreviewSceneService } from './PreviewSceneService';
import type { IViewportOverlay, OverlayRenderContext } from '../Rendering/IViewportOverlay';
const logger = createLogger('EditorViewportService');
/**
* 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)) {
logger.warn(`Viewport already registered: ${config.id}`);
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) {
logger.warn(`Viewport not found: ${viewportId}`);
return;
}
if (state.overlays.has(overlay.id)) {
logger.warn(`Overlay already exists: ${overlay.id} in ${viewportId}`);
return;
}
state.overlays.set(overlay.id, overlay);
logger.debug(`Added overlay: ${overlay.id} to ${viewportId}`);
}
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,47 @@
/**
* @zh 实体创建模板注册表
* @en Entity Creation Registry Service
*
* @zh 管理场景层级右键菜单中的实体创建模板
* @en Manages entity creation templates for the scene hierarchy context menu
*/
import { BaseRegistry, createRegistryToken } from './BaseRegistry';
import type { EntityCreationTemplate } from '../Types/UITypes';
/**
* @zh 实体创建模板注册表
* @en Entity Creation Registry
*/
export class EntityCreationRegistry extends BaseRegistry<EntityCreationTemplate> {
constructor() {
super('EntityCreationRegistry');
}
protected getItemKey(item: EntityCreationTemplate): string {
return item.id;
}
protected override getItemDisplayName(item: EntityCreationTemplate): string {
return `${item.label} (${item.id})`;
}
/**
* @zh 获取所有模板(按 order 排序)
* @en Get all templates sorted by order
*/
getAllSorted(): EntityCreationTemplate[] {
return this.sortByOrder(this.getAll(), 100);
}
/**
* @zh 获取指定分类的模板
* @en Get templates by category
*/
getByCategory(category: string): EntityCreationTemplate[] {
return this.sortByOrder(this.filter(t => t.category === category), 100);
}
}
/** @zh 实体创建模板注册表服务标识符 @en Entity creation registry service identifier */
export const IEntityCreationRegistry = createRegistryToken<EntityCreationRegistry>('EntityCreationRegistry');

View File

@@ -0,0 +1,137 @@
import { Injectable, IService, Entity, Core, HierarchyComponent, createLogger } from '@esengine/ecs-framework';
import { MessageHub } from './MessageHub';
const logger = createLogger('EntityStoreService');
export interface EntityTreeNode {
entity: Entity;
children: EntityTreeNode[];
parent: EntityTreeNode | null;
}
/**
* 管理编辑器中的实体状态和选择
*/
@Injectable()
export class EntityStoreService implements IService {
private entities: Map<number, Entity> = new Map();
private selectedEntity: Entity | null = null;
private rootEntityIds: number[] = [];
constructor(private messageHub: MessageHub) {}
public dispose(): void {
this.entities.clear();
this.rootEntityIds = [];
this.selectedEntity = null;
}
public addEntity(entity: Entity, parent?: Entity): void {
this.entities.set(entity.id, entity);
if (!parent && !this.rootEntityIds.includes(entity.id)) {
this.rootEntityIds.push(entity.id);
}
this.messageHub.publish('entity:added', { entity, parent });
}
public removeEntity(entity: Entity): void {
this.entities.delete(entity.id);
const idx = this.rootEntityIds.indexOf(entity.id);
if (idx !== -1) {
this.rootEntityIds.splice(idx, 1);
}
if (this.selectedEntity?.id === entity.id) {
this.selectedEntity = null;
this.messageHub.publish('entity:selected', { entity: null });
}
this.messageHub.publish('entity:removed', { entity });
}
public selectEntity(entity: Entity | null): void {
this.selectedEntity = entity;
this.messageHub.publish('entity:selected', { entity });
}
public getSelectedEntity(): Entity | null {
return this.selectedEntity;
}
public getAllEntities(): Entity[] {
return Array.from(this.entities.values());
}
public getRootEntities(): Entity[] {
return this.rootEntityIds
.map((id) => this.entities.get(id))
.filter((e): e is Entity => e !== undefined);
}
public getRootEntityIds(): number[] {
return [...this.rootEntityIds];
}
public getEntity(id: number): Entity | undefined {
return this.entities.get(id);
}
public clear(): void {
this.entities.clear();
this.rootEntityIds = [];
this.selectedEntity = null;
this.messageHub.publish('entities:cleared', {});
}
public syncFromScene(): void {
const scene = Core.scene;
if (!scene) {
logger.warn('syncFromScene called but no scene available');
return;
}
this.entities.clear();
this.rootEntityIds = [];
// 调试:打印场景实体信息 | Debug: print scene entity info
logger.info(`[syncFromScene] Scene name: ${scene.name}, entities.count: ${scene.entities.count}`);
let entityCount = 0;
scene.entities.forEach((entity) => {
entityCount++;
this.entities.set(entity.id, entity);
const hierarchy = entity.getComponent(HierarchyComponent);
const bHasNoParent = hierarchy?.parentId === null || hierarchy?.parentId === undefined;
if (bHasNoParent) {
this.rootEntityIds.push(entity.id);
}
});
logger.info(`[syncFromScene] Synced ${entityCount} entities, ${this.rootEntityIds.length} root entities`);
if (this.rootEntityIds.length > 0) {
const rootNames = this.rootEntityIds
.map(id => this.entities.get(id)?.name)
.join(', ');
logger.debug(`Root entities: ${rootNames}`);
}
}
public reorderEntity(entityId: number, newIndex: number): void {
const idx = this.rootEntityIds.indexOf(entityId);
if (idx === -1 || idx === newIndex) return;
const clampedIndex = Math.max(0, Math.min(newIndex, this.rootEntityIds.length - 1));
this.rootEntityIds.splice(idx, 1);
this.rootEntityIds.splice(clampedIndex, 0, entityId);
const scene = Core.scene;
if (scene) {
scene.entities.reorderEntity(entityId, clampedIndex);
}
this.messageHub.publish('entity:reordered', { entityId, newIndex: clampedIndex });
}
}

View File

@@ -0,0 +1,52 @@
/**
* @zh 字段编辑器注册表
* @en Field Editor Registry
*/
import { PrioritizedRegistry, createRegistryToken } from './BaseRegistry';
import type { IFieldEditor, IFieldEditorRegistry, FieldEditorContext } from './IFieldEditor';
/**
* @zh 字段编辑器注册表
* @en Field Editor Registry
*/
export class FieldEditorRegistry
extends PrioritizedRegistry<IFieldEditor>
implements IFieldEditorRegistry {
constructor() {
super('FieldEditorRegistry');
}
protected getItemKey(item: IFieldEditor): string {
return item.type;
}
protected override getItemDisplayName(item: IFieldEditor): string {
return `${item.name} (${item.type})`;
}
/**
* @zh 获取字段编辑器
* @en Get field editor
*/
getEditor(type: string, context?: FieldEditorContext): IFieldEditor | undefined {
// 先尝试精确匹配
const exact = this.get(type);
if (exact) return exact;
// 再按优先级查找可处理的编辑器
return this.findByPriority(editor => editor.canHandle(type, context));
}
/**
* @zh 获取所有编辑器
* @en Get all editors
*/
getAllEditors(): IFieldEditor[] {
return this.getAll();
}
}
/** @zh 字段编辑器注册表服务标识符 @en Field editor registry service identifier */
export const FieldEditorRegistryToken = createRegistryToken<FieldEditorRegistry>('FieldEditorRegistry');

View File

@@ -0,0 +1,163 @@
import { IService, createLogger, type ILogger } from '@esengine/ecs-framework';
import type { FileActionHandler, FileCreationTemplate } from '../Plugin/EditorModule';
import { createRegistryToken } from './BaseRegistry';
export type { FileCreationTemplate } from '../Plugin/EditorModule';
/**
* @zh 资产创建消息映射
* @en Asset creation message mapping
*/
export interface AssetCreationMapping {
/** @zh 文件扩展名(包含点号,如 '.tilemap' @en File extension (with dot) */
extension: string;
/** @zh 创建资产时发送的消息名 @en Message name to publish when creating asset */
createMessage: string;
/** @zh 是否支持创建(可选,默认 true @en Whether creation is supported */
canCreate?: boolean;
}
/** @zh FileActionRegistry 服务标识符 @en FileActionRegistry service identifier */
export const IFileActionRegistry = createRegistryToken<FileActionRegistry>('FileActionRegistry');
/**
* @zh 文件操作注册表服务 - 管理插件注册的文件操作处理器和文件创建模板
* @en File Action Registry Service - Manages file action handlers and creation templates
*/
export class FileActionRegistry implements IService {
private readonly _actionHandlers = new Map<string, FileActionHandler[]>();
private readonly _creationTemplates: FileCreationTemplate[] = [];
private readonly _assetCreationMappings = new Map<string, AssetCreationMapping>();
private readonly _logger: ILogger;
constructor() {
this._logger = createLogger('FileActionRegistry');
}
/**
* @zh 规范化扩展名(确保以 . 开头且小写)
* @en Normalize extension (ensure starts with . and lowercase)
*/
private _normalizeExtension(ext: string): string {
const lower = ext.toLowerCase();
return lower.startsWith('.') ? lower : `.${lower}`;
}
/** @zh 注册文件操作处理器 @en Register file action handler */
registerActionHandler(handler: FileActionHandler): void {
for (const ext of handler.extensions) {
const handlers = this._actionHandlers.get(ext) ?? [];
handlers.push(handler);
this._actionHandlers.set(ext, handlers);
}
}
/** @zh 注销文件操作处理器 @en Unregister file action handler */
unregisterActionHandler(handler: FileActionHandler): void {
for (const ext of handler.extensions) {
const handlers = this._actionHandlers.get(ext);
if (!handlers) continue;
const index = handlers.indexOf(handler);
if (index !== -1) handlers.splice(index, 1);
if (handlers.length === 0) this._actionHandlers.delete(ext);
}
}
/** @zh 注册文件创建模板 @en Register file creation template */
registerCreationTemplate(template: FileCreationTemplate): void {
this._creationTemplates.push(template);
}
/** @zh 注销文件创建模板 @en Unregister file creation template */
unregisterCreationTemplate(template: FileCreationTemplate): void {
const index = this._creationTemplates.indexOf(template);
if (index !== -1) this._creationTemplates.splice(index, 1);
}
/** @zh 获取文件扩展名的处理器 @en Get handlers for extension */
getHandlersForExtension(extension: string): FileActionHandler[] {
return this._actionHandlers.get(extension) ?? [];
}
/** @zh 获取文件的处理器 @en Get handlers for file */
getHandlersForFile(filePath: string): FileActionHandler[] {
const ext = this._extractFileExtension(filePath);
return ext ? this.getHandlersForExtension(ext) : [];
}
/** @zh 获取所有文件创建模板 @en Get all creation templates */
getCreationTemplates(): FileCreationTemplate[] {
return this._creationTemplates;
}
/** @zh 处理文件双击 @en Handle file double click */
async handleDoubleClick(filePath: string): Promise<boolean> {
for (const handler of this.getHandlersForFile(filePath)) {
if (handler.onDoubleClick) {
await handler.onDoubleClick(filePath);
return true;
}
}
return false;
}
/** @zh 处理文件打开 @en Handle file open */
async handleOpen(filePath: string): Promise<boolean> {
for (const handler of this.getHandlersForFile(filePath)) {
if (handler.onOpen) {
await handler.onOpen(filePath);
return true;
}
}
return false;
}
/** @zh 注册资产创建消息映射 @en Register asset creation mapping */
registerAssetCreationMapping(mapping: AssetCreationMapping): void {
const ext = this._normalizeExtension(mapping.extension);
this._assetCreationMappings.set(ext, { ...mapping, extension: ext });
this._logger.debug(`Registered asset creation mapping: ${ext}`);
}
/** @zh 注销资产创建消息映射 @en Unregister asset creation mapping */
unregisterAssetCreationMapping(extension: string): void {
const ext = this._normalizeExtension(extension);
if (this._assetCreationMappings.delete(ext)) {
this._logger.debug(`Unregistered asset creation mapping: ${ext}`);
}
}
/** @zh 获取扩展名对应的资产创建消息映射 @en Get asset creation mapping for extension */
getAssetCreationMapping(extension: string): AssetCreationMapping | undefined {
return this._assetCreationMappings.get(this._normalizeExtension(extension));
}
/** @zh 检查扩展名是否支持创建资产 @en Check if extension supports asset creation */
canCreateAsset(extension: string): boolean {
return this.getAssetCreationMapping(extension)?.canCreate !== false;
}
/** @zh 获取所有资产创建映射 @en Get all asset creation mappings */
getAllAssetCreationMappings(): AssetCreationMapping[] {
return Array.from(this._assetCreationMappings.values());
}
/** @zh 清空所有注册 @en Clear all registrations */
clear(): void {
this._actionHandlers.clear();
this._creationTemplates.length = 0;
this._assetCreationMappings.clear();
}
/** @zh 释放资源 @en Dispose resources */
dispose(): void {
this.clear();
}
/** @zh 提取文件扩展名 @en Extract file extension */
private _extractFileExtension(filePath: string): string | null {
const lastDot = filePath.lastIndexOf('.');
return lastDot === -1 ? null : filePath.substring(lastDot + 1).toLowerCase();
}
}

View File

@@ -0,0 +1,362 @@
/**
* Gizmo Interaction Service
* Gizmo 交互服务
*
* Manages gizmo hover detection, highlighting, and click selection.
* 管理 Gizmo 的悬停检测、高亮显示和点击选择。
*/
import { Core } from '@esengine/ecs-framework';
import type { Entity, ComponentType } from '@esengine/ecs-framework';
import { GizmoHitTester } from '../Gizmos/GizmoHitTester';
import { GizmoRegistry } from '../Gizmos/GizmoRegistry';
import type { IGizmoRenderData, GizmoColor } from '../Gizmos/IGizmoProvider';
/**
* Gizmo hit result
* Gizmo 命中结果
*/
export interface GizmoHitResult {
/** Hit gizmo data | 命中的 Gizmo 数据 */
gizmo: IGizmoRenderData;
/** Associated entity ID | 关联的实体 ID */
entityId: number;
/** Distance from hit point to gizmo center | 命中点到 Gizmo 中心的距离 */
distance: number;
/** Virtual node ID if this gizmo represents a virtual node | 虚拟节点 ID如果此 gizmo 代表虚拟节点) */
virtualNodeId?: string;
}
/**
* Click result with entity and optional virtual node
* 点击结果,包含实体和可选的虚拟节点
*/
export interface GizmoClickResult {
/** Entity ID | 实体 ID */
entityId: number;
/** Virtual node ID if clicked on a virtual node gizmo | 虚拟节点 ID如果点击了虚拟节点 gizmo */
virtualNodeId?: string;
}
/**
* Gizmo interaction service interface
* Gizmo 交互服务接口
*/
export interface IGizmoInteractionService {
/**
* Get currently hovered entity ID
* 获取当前悬停的实体 ID
*/
getHoveredEntityId(): number | null;
/**
* Update mouse position and perform hit test
* 更新鼠标位置并执行命中测试
*
* @param worldX World X coordinate | 世界 X 坐标
* @param worldY World Y coordinate | 世界 Y 坐标
* @param zoom Current viewport zoom level | 当前视口缩放级别
*/
updateMousePosition(worldX: number, worldY: number, zoom: number): void;
/**
* Get highlight color for entity (applies hover effect if applicable)
* 获取实体的高亮颜色(如果适用则应用悬停效果)
*
* @param entityId Entity ID | 实体 ID
* @param baseColor Base gizmo color | 基础 Gizmo 颜色
* @param isSelected Whether entity is selected | 实体是否被选中
* @returns Adjusted color | 调整后的颜色
*/
getHighlightColor(entityId: number, baseColor: GizmoColor, isSelected: boolean): GizmoColor;
/**
* Handle click at position, return hit entity ID
* 处理位置点击,返回命中的实体 ID
*
* @param worldX World X coordinate | 世界 X 坐标
* @param worldY World Y coordinate | 世界 Y 坐标
* @param zoom Current viewport zoom level | 当前视口缩放级别
* @returns Hit entity ID or null | 命中的实体 ID 或 null
*/
handleClick(worldX: number, worldY: number, zoom: number): number | null;
/**
* Clear hover state
* 清除悬停状态
*/
clearHover(): void;
/**
* Handle click at position with virtual node support
* 处理位置点击,支持虚拟节点
*
* @param worldX World X coordinate | 世界 X 坐标
* @param worldY World Y coordinate | 世界 Y 坐标
* @param zoom Current viewport zoom level | 当前视口缩放级别
* @returns Click result with entity and optional virtual node | 点击结果
*/
handleClickEx(worldX: number, worldY: number, zoom: number): GizmoClickResult | null;
/**
* Get currently hovered virtual node ID
* 获取当前悬停的虚拟节点 ID
*/
getHoveredVirtualNodeId(): string | null;
}
/**
* Gizmo Interaction Service
* Gizmo 交互服务
*
* Manages gizmo hover detection, highlighting, and click selection.
* 管理 Gizmo 的悬停检测、高亮显示和点击选择。
*/
export class GizmoInteractionService implements IGizmoInteractionService {
private hoveredEntityId: number | null = null;
private hoveredGizmo: IGizmoRenderData | null = null;
private hoveredVirtualNodeId: string | null = null;
/** Hover color multiplier for RGB channels | 悬停时 RGB 通道的颜色倍增 */
private static readonly HOVER_COLOR_MULTIPLIER = 1.3;
/** Hover alpha boost | 悬停时 Alpha 增量 */
private static readonly HOVER_ALPHA_BOOST = 0.3;
// ===== Click cycling state | 点击循环状态 =====
/** Last click position | 上次点击位置 */
private lastClickPos: { x: number; y: number } | null = null;
/** Last click time | 上次点击时间 */
private lastClickTime: number = 0;
/** All hit results at current click position | 当前点击位置的所有命中结果 */
private hitResultsAtClick: GizmoClickResult[] = [];
/** Current cycle index | 当前循环索引 */
private cycleIndex: number = 0;
/** Position tolerance for same-position detection | 判断相同位置的容差 */
private static readonly CLICK_POSITION_TOLERANCE = 5;
/** Time tolerance for cycling (ms) | 循环的时间容差(毫秒) */
private static readonly CLICK_TIME_TOLERANCE = 1000;
/**
* Get currently hovered entity ID
* 获取当前悬停的实体 ID
*/
getHoveredEntityId(): number | null {
return this.hoveredEntityId;
}
/**
* Get currently hovered gizmo data
* 获取当前悬停的 Gizmo 数据
*/
getHoveredGizmo(): IGizmoRenderData | null {
return this.hoveredGizmo;
}
/**
* Get currently hovered virtual node ID
* 获取当前悬停的虚拟节点 ID
*/
getHoveredVirtualNodeId(): string | null {
return this.hoveredVirtualNodeId;
}
/**
* Update mouse position and perform hit test
* 更新鼠标位置并执行命中测试
*/
updateMousePosition(worldX: number, worldY: number, zoom: number): void {
const scene = Core.scene;
if (!scene) {
this.hoveredEntityId = null;
this.hoveredGizmo = null;
this.hoveredVirtualNodeId = null;
return;
}
let closestHit: GizmoHitResult | null = null;
let closestDistance = Infinity;
// Iterate all entities and collect gizmo data for hit testing
// 遍历所有实体,收集 gizmo 数据进行命中测试
for (const entity of scene.entities.buffer) {
// Skip entities without gizmo providers
// 跳过没有 gizmo 提供者的实体
if (!GizmoRegistry.hasAnyGizmoProvider(entity)) {
continue;
}
for (const component of entity.components) {
const componentType = component.constructor as ComponentType;
if (!GizmoRegistry.hasProvider(componentType)) {
continue;
}
const gizmos = GizmoRegistry.getGizmoData(component, entity, false);
for (const gizmo of gizmos) {
if (GizmoHitTester.hitTest(worldX, worldY, gizmo, zoom)) {
// Calculate distance to gizmo center for sorting
// 计算到 gizmo 中心的距离用于排序
const center = GizmoHitTester.getGizmoCenter(gizmo);
const distance = Math.sqrt(
(worldX - center.x) ** 2 + (worldY - center.y) ** 2
);
if (distance < closestDistance) {
closestDistance = distance;
closestHit = {
gizmo,
entityId: entity.id,
distance,
virtualNodeId: gizmo.virtualNodeId
};
}
}
}
}
}
this.hoveredEntityId = closestHit?.entityId ?? null;
this.hoveredGizmo = closestHit?.gizmo ?? null;
this.hoveredVirtualNodeId = closestHit?.virtualNodeId ?? null;
}
/**
* Get highlight color for entity
* 获取实体的高亮颜色
*/
getHighlightColor(entityId: number, baseColor: GizmoColor, isSelected: boolean): GizmoColor {
const isHovered = entityId === this.hoveredEntityId;
if (!isHovered) {
return baseColor;
}
// Apply hover highlight: brighten color and increase alpha
// 应用悬停高亮:提亮颜色并增加透明度
return {
r: Math.min(1, baseColor.r * GizmoInteractionService.HOVER_COLOR_MULTIPLIER),
g: Math.min(1, baseColor.g * GizmoInteractionService.HOVER_COLOR_MULTIPLIER),
b: Math.min(1, baseColor.b * GizmoInteractionService.HOVER_COLOR_MULTIPLIER),
a: Math.min(1, baseColor.a + GizmoInteractionService.HOVER_ALPHA_BOOST)
};
}
/**
* Handle click at position, return hit entity ID
* Supports cycling through overlapping entities on repeated clicks
* 处理位置点击,返回命中的实体 ID
* 支持重复点击时循环选择重叠的实体
*/
handleClick(worldX: number, worldY: number, zoom: number): number | null {
const result = this.handleClickEx(worldX, worldY, zoom);
return result?.entityId ?? null;
}
/**
* Handle click at position with virtual node support
* Supports cycling through overlapping gizmos on repeated clicks
* 处理位置点击,支持虚拟节点
* 支持重复点击时循环选择重叠的 gizmos
*/
handleClickEx(worldX: number, worldY: number, zoom: number): GizmoClickResult | null {
const now = Date.now();
const isSamePosition = this.lastClickPos !== null &&
Math.abs(worldX - this.lastClickPos.x) < GizmoInteractionService.CLICK_POSITION_TOLERANCE / zoom &&
Math.abs(worldY - this.lastClickPos.y) < GizmoInteractionService.CLICK_POSITION_TOLERANCE / zoom;
const isWithinTimeWindow = (now - this.lastClickTime) < GizmoInteractionService.CLICK_TIME_TOLERANCE;
// If clicking at same position within time window, cycle to next result
// 如果在时间窗口内点击相同位置,循环到下一个结果
if (isSamePosition && isWithinTimeWindow && this.hitResultsAtClick.length > 1) {
this.cycleIndex = (this.cycleIndex + 1) % this.hitResultsAtClick.length;
this.lastClickTime = now;
const result = this.hitResultsAtClick[this.cycleIndex];
this.hoveredEntityId = result.entityId;
this.hoveredVirtualNodeId = result.virtualNodeId ?? null;
return result;
}
// New position or timeout - collect all hit results
// 新位置或超时 - 收集所有命中结果
this.hitResultsAtClick = this.collectAllHitResults(worldX, worldY, zoom);
this.cycleIndex = 0;
this.lastClickPos = { x: worldX, y: worldY };
this.lastClickTime = now;
if (this.hitResultsAtClick.length > 0) {
const result = this.hitResultsAtClick[0];
this.hoveredEntityId = result.entityId;
this.hoveredVirtualNodeId = result.virtualNodeId ?? null;
return result;
}
return null;
}
/**
* Collect all hit results at the given position, sorted by distance
* 收集给定位置的所有命中结果,按距离排序
*/
private collectAllHitResults(worldX: number, worldY: number, zoom: number): GizmoClickResult[] {
const scene = Core.scene;
if (!scene) return [];
const hits: Array<GizmoClickResult & { distance: number }> = [];
for (const entity of scene.entities.buffer) {
if (!GizmoRegistry.hasAnyGizmoProvider(entity)) {
continue;
}
for (const component of entity.components) {
const componentType = component.constructor as ComponentType;
if (!GizmoRegistry.hasProvider(componentType)) {
continue;
}
const gizmos = GizmoRegistry.getGizmoData(component, entity, false);
for (const gizmo of gizmos) {
if (GizmoHitTester.hitTest(worldX, worldY, gizmo, zoom)) {
const center = GizmoHitTester.getGizmoCenter(gizmo);
const distance = Math.sqrt(
(worldX - center.x) ** 2 + (worldY - center.y) ** 2
);
hits.push({
entityId: entity.id,
virtualNodeId: gizmo.virtualNodeId,
distance
});
}
}
}
}
// Sort by distance (closest first)
// 按距离排序(最近的在前)
hits.sort((a, b) => a.distance - b.distance);
// Remove duplicates (same entity + virtualNodeId), keeping closest
// 去重(相同实体 + virtualNodeId保留最近的
const seen = new Set<string>();
const uniqueHits: GizmoClickResult[] = [];
for (const hit of hits) {
const key = `${hit.entityId}:${hit.virtualNodeId ?? ''}`;
if (!seen.has(key)) {
seen.add(key);
uniqueHits.push({ entityId: hit.entityId, virtualNodeId: hit.virtualNodeId });
}
}
return uniqueHits;
}
/**
* Clear hover state
* 清除悬停状态
*/
clearHover(): void {
this.hoveredEntityId = null;
this.hoveredGizmo = null;
this.hoveredVirtualNodeId = null;
}
}

View File

@@ -0,0 +1,19 @@
export interface ICommand {
execute(): void | Promise<void>;
undo(): void | Promise<void>;
getDescription(): string;
canMergeWith(other: ICommand): boolean;
mergeWith(other: ICommand): ICommand;
}
export interface ICommandManager {
execute(command: ICommand): void;
executeBatch(commands: ICommand[]): void;
undo(): void;
redo(): void;
canUndo(): boolean;
canRedo(): boolean;
clear(): void;
getUndoHistory(): string[];
getRedoHistory(): string[];
}

View File

@@ -0,0 +1,33 @@
export interface CompileResult {
success: boolean;
message: string;
outputFiles?: string[];
errors?: string[];
}
import type { IFileSystem } from './IFileSystem';
import type { IDialog } from './IDialog';
import type { IService, ServiceType } from '@esengine/ecs-framework';
export interface CompilerModuleContext {
fileSystem: IFileSystem;
dialog: IDialog;
}
export interface CompilerContext {
projectPath: string | null;
moduleContext: CompilerModuleContext;
getService<T extends IService>(serviceClass: ServiceType<T>): T | undefined;
}
export interface ICompiler<TOptions = unknown> {
readonly id: string;
readonly name: string;
readonly description: string;
compile(options: TOptions, context: CompilerContext): Promise<CompileResult>;
createConfigUI?(onOptionsChange: (options: TOptions) => void, context: CompilerContext): React.ReactElement;
validateOptions?(options: TOptions): string | null;
}

View File

@@ -0,0 +1,26 @@
export interface DialogOptions {
title?: string;
defaultPath?: string;
filters?: Array<{ name: string; extensions: string[] }>;
}
export interface OpenDialogOptions extends DialogOptions {
directory?: boolean;
multiple?: boolean;
}
export interface SaveDialogOptions extends DialogOptions {
defaultFileName?: string;
}
export interface IDialog {
dispose(): void;
openDialog(options: OpenDialogOptions): Promise<string | string[] | null>;
saveDialog(options: SaveDialogOptions): Promise<string | null>;
showMessage(title: string, message: string, type?: 'info' | 'warning' | 'error'): Promise<void>;
showConfirm(title: string, message: string): Promise<boolean>;
}
// Service identifier for DI registration
// 使用 Symbol.for 确保跨包共享同一个 Symbol
export const IDialogService = Symbol.for('IDialogService');

View File

@@ -0,0 +1,12 @@
export interface IEditorDataStore<TNode, TConnection> {
getNodes(): TNode[];
getConnections(): TConnection[];
getNode(nodeId: string): TNode | undefined;
getConnection(connectionId: string): TConnection | undefined;
addNode(node: TNode): void;
removeNode(nodeId: string): void;
updateNode(nodeId: string, updates: Partial<TNode>): void;
addConnection(connection: TConnection): void;
removeConnection(connectionId: string): void;
clear(): void;
}

View File

@@ -0,0 +1,49 @@
import { ReactElement } from 'react';
export interface FieldEditorContext {
readonly?: boolean;
projectPath?: string;
decimalPlaces?: number;
metadata?: Record<string, any>;
}
export interface FieldEditorProps<T = any> {
label: string;
value: T;
onChange: (value: T) => void;
context: FieldEditorContext;
}
export interface IFieldEditor<T = any> {
readonly type: string;
readonly name: string;
readonly priority?: number;
canHandle(fieldType: string, context?: FieldEditorContext): boolean;
render(props: FieldEditorProps<T>): ReactElement;
}
export interface IFieldEditorRegistry {
register(editor: IFieldEditor): void;
unregister(type: string): void;
getEditor(type: string, context?: FieldEditorContext): IFieldEditor | undefined;
getAllEditors(): IFieldEditor[];
}
export interface FieldMetadata {
type: string;
options?: {
/** 资源类型 | Asset type */
assetType?: string;
/** 文件扩展名过滤 | File extension filter */
extensions?: string[];
/** 枚举选项 | Enum values */
enumValues?: Array<string | { value: string; label: string }>;
min?: number;
max?: number;
step?: number;
language?: string;
multiline?: boolean;
placeholder?: string;
};
}

View File

@@ -0,0 +1,30 @@
export interface IFileSystem {
dispose(): void;
readFile(path: string): Promise<string>;
writeFile(path: string, content: string): Promise<void>;
writeBinary(path: string, data: Uint8Array): Promise<void>;
exists(path: string): Promise<boolean>;
createDirectory(path: string): Promise<void>;
listDirectory(path: string): Promise<FileEntry[]>;
deleteFile(path: string): Promise<void>;
deleteDirectory(path: string): Promise<void>;
scanFiles(basePath: string, pattern: string): Promise<string[]>;
/**
* Convert a local file path to an asset URL that can be used in browser contexts (img src, audio src, etc.)
* @param filePath The local file path
* @returns The converted asset URL
*/
convertToAssetUrl(filePath: string): string;
}
export interface FileEntry {
name: string;
path: string;
isDirectory: boolean;
size?: number;
modified?: Date;
}
// Service identifier for DI registration
// 使用 Symbol.for 确保跨包共享同一个 Symbol
export const IFileSystemService = Symbol.for('IFileSystemService');

View File

@@ -0,0 +1,77 @@
import React from 'react';
/**
* Inspector提供器接口
* 用于扩展Inspector面板支持不同类型的对象检视
*/
export interface IInspectorProvider<T = unknown> {
/**
* 提供器唯一标识
*/
readonly id: string;
/**
* 提供器名称
*/
readonly name: string;
/**
* 优先级,数字越大优先级越高
*/
readonly priority?: number;
/**
* 判断是否可以处理该目标
*/
canHandle(target: unknown): target is T;
/**
* 渲染Inspector内容
*/
render(target: T, context: InspectorContext): React.ReactElement;
}
/**
* Inspector上下文
*/
export interface InspectorContext {
/**
* 当前选中的目标
*/
target: unknown;
/**
* 是否只读模式
*/
readonly?: boolean;
/**
* 项目路径
*/
projectPath?: string | null;
/**
* 额外的上下文数据
*/
[key: string]: unknown;
}
/**
* Inspector目标类型
*/
export interface InspectorTarget<T = unknown> {
/**
* 目标类型
*/
type: string;
/**
* 目标数据
*/
data: T;
/**
* 额外的元数据
*/
metadata?: Record<string, unknown>;
}

View File

@@ -0,0 +1,6 @@
export type NotificationType = 'success' | 'error' | 'warning' | 'info';
export interface INotification {
show(message: string, type?: NotificationType, duration?: number): void;
hide(id: string): void;
}

View File

@@ -0,0 +1,30 @@
import { ReactElement } from 'react';
export interface PropertyContext {
readonly name: string;
readonly path?: string[];
readonly depth?: number;
readonly readonly?: boolean;
readonly decimalPlaces?: number;
readonly expandByDefault?: boolean;
readonly parentObject?: any;
readonly metadata?: Record<string, any>;
}
export interface IPropertyRenderer<T = any> {
readonly id: string;
readonly name: string;
readonly priority?: number;
canHandle(value: any, context: PropertyContext): value is T;
render(value: T, context: PropertyContext): ReactElement;
}
export interface IPropertyRendererRegistry {
register(renderer: IPropertyRenderer): void;
unregister(rendererId: string): void;
findRenderer(value: any, context: PropertyContext): IPropertyRenderer | undefined;
render(value: any, context: PropertyContext): ReactElement | null;
getAllRenderers(): IPropertyRenderer[];
hasRenderer(value: any, context: PropertyContext): boolean;
}

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,58 @@
/**
* @zh Inspector 注册表
* @en Inspector Registry
*/
import React from 'react';
import { PrioritizedRegistry, createRegistryToken } from './BaseRegistry';
import type { IInspectorProvider, InspectorContext } from './IInspectorProvider';
/**
* @zh Inspector 注册表
* @en Inspector Registry
*/
export class InspectorRegistry extends PrioritizedRegistry<IInspectorProvider> {
constructor() {
super('InspectorRegistry');
}
protected getItemKey(item: IInspectorProvider): string {
return item.id;
}
/**
* @zh 获取指定 ID 的提供器
* @en Get provider by ID
*/
getProvider(providerId: string): IInspectorProvider | undefined {
return this.get(providerId);
}
/**
* @zh 获取所有提供器
* @en Get all providers
*/
getAllProviders(): IInspectorProvider[] {
return this.getAll();
}
/**
* @zh 查找可以处理指定目标的提供器
* @en Find provider that can handle the target
*/
findProvider(target: unknown): IInspectorProvider | undefined {
return this.findByPriority(provider => provider.canHandle(target));
}
/**
* @zh 渲染 Inspector 内容
* @en Render inspector content
*/
render(target: unknown, context: InspectorContext): React.ReactElement | null {
const provider = this.findProvider(target);
return provider?.render(target, context) ?? null;
}
}
/** @zh Inspector 注册表服务标识符 @en Inspector registry service identifier */
export const IInspectorRegistry = createRegistryToken<InspectorRegistry>('InspectorRegistry');

View File

@@ -0,0 +1,367 @@
import type { IService } from '@esengine/ecs-framework';
import { Injectable } from '@esengine/ecs-framework';
import { createLogger } from '@esengine/ecs-framework';
const logger = createLogger('LocaleService');
/**
* 支持的语言类型
* Supported locale types
*
* - en: English
* - zh: 简体中文 (Simplified Chinese)
* - es: Español (Spanish)
*/
export type Locale = 'en' | 'zh' | 'es';
/**
* 语言显示信息
* Locale display information
*/
export interface LocaleInfo {
code: Locale;
name: string;
nativeName: string;
}
/**
* 支持的语言列表
* List of supported locales
*/
export const SUPPORTED_LOCALES: readonly LocaleInfo[] = [
{ code: 'en', name: 'English', nativeName: 'English' },
{ code: 'zh', name: 'Chinese', nativeName: '简体中文' },
{ code: 'es', name: 'Spanish', nativeName: 'Español' }
] as const;
/**
* 翻译值类型
* Translation value type
*/
export interface Translations {
[key: string]: string | Translations;
}
/**
* 插件翻译包
* Plugin translation bundle
*
* 用于插件注册自己的翻译
* Used for plugins to register their own translations
*/
export interface PluginTranslations {
en: Translations;
zh: Translations;
es?: Translations;
}
/**
* 翻译参数类型
* Translation parameters type
*/
export type TranslationParams = Record<string, string | number>;
/**
* 国际化服务
* Internationalization service
*
* 管理编辑器的多语言支持,提供翻译、语言切换和事件通知功能。
* Manages editor's multi-language support, provides translation, locale switching and event notification.
*
* @example
* ```typescript
* // 获取服务 | Get service
* const localeService = Core.services.resolve(LocaleService);
*
* // 翻译文本 | Translate text
* localeService.t('common.save'); // "Save" or "保存"
*
* // 带参数的翻译 | Translation with parameters
* localeService.t('scene.savedSuccess', { name: 'MyScene' }); // "Scene saved: MyScene"
*
* // 切换语言 | Switch locale
* localeService.setLocale('zh');
*
* // 插件注册翻译 | Plugin register translations
* localeService.extendTranslations('behaviorTree', {
* en: { title: 'Behavior Tree Editor', ... },
* zh: { title: '行为树编辑器', ... }
* });
* ```
*/
@Injectable()
export class LocaleService implements IService {
private _currentLocale: Locale = 'en';
private _translations: Map<Locale, Translations> = new Map();
private _changeListeners: Set<(locale: Locale) => void> = new Set();
constructor() {
const savedLocale = this._loadSavedLocale();
if (savedLocale) {
this._currentLocale = savedLocale;
}
}
/**
* 注册核心语言包(覆盖式)
* Register core translations (overwrites existing)
*
* 用于编辑器核心初始化时注册基础翻译
* Used for editor core to register base translations during initialization
*
* @param locale - 语言代码 | Locale code
* @param translations - 翻译对象 | Translation object
*/
public registerTranslations(locale: Locale, translations: Translations): void {
this._translations.set(locale, translations);
logger.info(`Registered translations for locale: ${locale}`);
}
/**
* 扩展语言包(合并式)
* Extend translations (merges with existing)
*
* 用于插件注册自己的翻译,会合并到现有翻译中
* Used for plugins to register their translations, merges with existing
*
* @param namespace - 命名空间,如 'behaviorTree' | Namespace, e.g. 'behaviorTree'
* @param pluginTranslations - 插件翻译包 | Plugin translation bundle
*
* @example
* ```typescript
* // 在插件的 editorModule.install() 中调用
* // Call in plugin's editorModule.install()
* localeService.extendTranslations('behaviorTree', {
* en: {
* title: 'Behavior Tree Editor',
* nodePalette: 'Node Palette',
* // ...
* },
* zh: {
* title: '行为树编辑器',
* nodePalette: '节点面板',
* // ...
* }
* });
*
* // 然后在组件中使用
* // Then use in components
* t('behaviorTree.title') // "Behavior Tree Editor" or "行为树编辑器"
* ```
*/
public extendTranslations(namespace: string, pluginTranslations: PluginTranslations): void {
const locales: Locale[] = ['en', 'zh', 'es'];
for (const locale of locales) {
const existing = this._translations.get(locale) || {};
const pluginTrans = pluginTranslations[locale];
if (pluginTrans) {
// 深度合并到命名空间下 | Deep merge under namespace
const merged = {
...existing,
[namespace]: this._deepMerge(
(existing[namespace] as Translations) || {},
pluginTrans
)
};
this._translations.set(locale, merged);
}
}
logger.info(`Extended translations for namespace: ${namespace}`);
}
/**
* 深度合并两个翻译对象
* Deep merge two translation objects
*/
private _deepMerge(target: Translations, source: Translations): Translations {
const result: Translations = { ...target };
for (const key of Object.keys(source)) {
const sourceValue = source[key];
const targetValue = target[key];
if (
typeof sourceValue === 'object' &&
sourceValue !== null &&
typeof targetValue === 'object' &&
targetValue !== null
) {
result[key] = this._deepMerge(
targetValue as Translations,
sourceValue as Translations
);
} else {
result[key] = sourceValue;
}
}
return result;
}
/**
* 获取当前语言
* Get current locale
*/
public getCurrentLocale(): Locale {
return this._currentLocale;
}
/**
* 获取支持的语言列表
* Get list of supported locales
*/
public getSupportedLocales(): readonly LocaleInfo[] {
return SUPPORTED_LOCALES;
}
/**
* 设置当前语言
* Set current locale
*
* @param locale - 目标语言代码 | Target locale code
*/
public setLocale(locale: Locale): void {
if (!this._translations.has(locale)) {
logger.warn(`Translations not found for locale: ${locale}`);
return;
}
this._currentLocale = locale;
this._saveLocale(locale);
this._changeListeners.forEach((listener) => listener(locale));
logger.info(`Locale changed to: ${locale}`);
}
/**
* 翻译文本
* Translate text
*
* @param key - 翻译键,支持点分隔的路径 | Translation key, supports dot-separated paths
* @param params - 可选的参数对象,用于替换模板中的占位符 {{key}} | Optional params for placeholder substitution
* @param fallback - 如果找不到翻译时的回退文本 | Fallback text if translation not found
*
* @example
* ```typescript
* // 简单翻译 | Simple translation
* t('common.save') // "Save"
*
* // 带参数替换 | With parameter substitution
* t('scene.savedSuccess', { name: 'MyScene' }) // "Scene saved: MyScene"
*
* // 插件翻译 | Plugin translation
* t('behaviorTree.title') // "Behavior Tree Editor"
*
* // 带回退文本 | With fallback
* t('unknown.key', undefined, 'Default Text') // "Default Text"
* ```
*/
public t(key: string, params?: TranslationParams, fallback?: string): string {
const translations = this._translations.get(this._currentLocale);
if (!translations) {
return fallback || key;
}
const value = this._getNestedValue(translations, key);
if (typeof value === 'string') {
// 支持参数替换 {{key}} | Support parameter substitution {{key}}
if (params) {
return value.replace(/\{\{(\w+)\}\}/g, (_, paramKey) => {
return String(params[paramKey] ?? `{{${paramKey}}}`);
});
}
return value;
}
return fallback || key;
}
/**
* 监听语言变化
* Listen to locale changes
*
* @param listener - 回调函数 | Callback function
* @returns 取消订阅函数 | Unsubscribe function
*/
public onChange(listener: (locale: Locale) => void): () => void {
this._changeListeners.add(listener);
return () => {
this._changeListeners.delete(listener);
};
}
/**
* 检查翻译键是否存在
* Check if a translation key exists
*
* @param key - 翻译键 | Translation key
* @param locale - 可选的语言代码,默认使用当前语言 | Optional locale, defaults to current
*/
public hasKey(key: string, locale?: Locale): boolean {
const targetLocale = locale || this._currentLocale;
const translations = this._translations.get(targetLocale);
if (!translations) {
return false;
}
const value = this._getNestedValue(translations, key);
return typeof value === 'string';
}
/**
* 获取嵌套对象的值
* Get nested object value
*/
private _getNestedValue(obj: Translations, path: string): string | Translations | undefined {
const keys = path.split('.');
let current: string | Translations | undefined = obj;
for (const key of keys) {
if (current && typeof current === 'object' && key in current) {
current = current[key];
} else {
return undefined;
}
}
return current;
}
/**
* 从 localStorage 加载保存的语言设置
* Load saved locale from localStorage
*/
private _loadSavedLocale(): Locale | null {
try {
const saved = localStorage.getItem('editor-locale');
if (saved === 'en' || saved === 'zh' || saved === 'es') {
return saved;
}
} catch (error) {
logger.warn('Failed to load saved locale:', error);
}
return null;
}
/**
* 保存语言设置到 localStorage
* Save locale to localStorage
*/
private _saveLocale(locale: Locale): void {
try {
localStorage.setItem('editor-locale', locale);
} catch (error) {
logger.warn('Failed to save locale:', error);
}
}
public dispose(): void {
this._translations.clear();
this._changeListeners.clear();
logger.info('LocaleService disposed');
}
}

View File

@@ -0,0 +1,236 @@
import type { IService } from '@esengine/ecs-framework';
import { Injectable, LogLevel } from '@esengine/ecs-framework';
export interface LogEntry {
id: number;
timestamp: Date;
level: LogLevel;
source: string;
message: string;
args: unknown[];
stack?: string; // 调用堆栈
clientId?: string; // 远程客户端ID
}
export type LogListener = (entry: LogEntry) => void;
/**
* 编辑器日志服务
*
* 捕获框架和用户代码的所有日志输出并提供给UI层展示
*/
@Injectable()
export class LogService implements IService {
private logs: LogEntry[] = [];
private listeners: Set<LogListener> = new Set();
private nextId = Date.now(); // 使用时间戳作为起始ID避免重复
private maxLogs = 1000;
private pendingNotifications: LogEntry[] = [];
private notificationScheduled = false;
private originalConsole = {
log: console.log.bind(console),
debug: console.debug.bind(console),
info: console.info.bind(console),
warn: console.warn.bind(console),
error: console.error.bind(console)
};
constructor() {
this.interceptConsole();
}
/**
* 拦截控制台输出
*/
private interceptConsole(): void {
console.log = (...args: unknown[]) => {
this.addLog(LogLevel.Info, 'console', this.formatMessage(args), args);
this.originalConsole.log(...args);
};
console.debug = (...args: unknown[]) => {
this.addLog(LogLevel.Debug, 'console', this.formatMessage(args), args);
this.originalConsole.debug(...args);
};
console.info = (...args: unknown[]) => {
this.addLog(LogLevel.Info, 'console', this.formatMessage(args), args);
this.originalConsole.info(...args);
};
console.warn = (...args: unknown[]) => {
this.addLog(LogLevel.Warn, 'console', this.formatMessage(args), args, true);
this.originalConsole.warn(...args);
};
console.error = (...args: unknown[]) => {
this.addLog(LogLevel.Error, 'console', this.formatMessage(args), args, true);
this.originalConsole.error(...args);
};
window.addEventListener('error', (event) => {
this.addLog(
LogLevel.Error,
'error',
event.message,
[event.error]
);
});
window.addEventListener('unhandledrejection', (event) => {
this.addLog(
LogLevel.Error,
'promise',
`Unhandled Promise Rejection: ${event.reason}`,
[event.reason]
);
});
}
/**
* 格式化消息
*/
private formatMessage(args: unknown[]): string {
return args.map((arg) => {
if (typeof arg === 'string') return arg;
if (arg instanceof Error) {
// 包含错误消息和堆栈
return arg.stack || arg.message;
}
try {
return JSON.stringify(arg);
} catch {
return String(arg);
}
}).join(' ');
}
/**
* 捕获当前调用堆栈
*/
private captureStack(): string {
const stack = new Error().stack;
if (!stack) return '';
// 移除前几行Error、captureStack、addLog、console.xxx
const lines = stack.split('\n');
return lines.slice(4).join('\n');
}
/**
* 添加日志
*/
private addLog(level: LogLevel, source: string, message: string, args: unknown[], includeStack = false): void {
const entry: LogEntry = {
id: this.nextId++,
timestamp: new Date(),
level,
source,
message,
args,
stack: includeStack ? this.captureStack() : undefined
};
this.logs.push(entry);
if (this.logs.length > this.maxLogs) {
this.logs.shift();
}
this.notifyListeners(entry);
}
/**
* 添加远程日志(从远程游戏接收)
*/
public addRemoteLog(level: LogLevel, message: string, timestamp?: Date, clientId?: string): void {
const entry: LogEntry = {
id: this.nextId++,
timestamp: timestamp || new Date(),
level,
source: 'remote',
message,
args: [],
clientId
};
this.logs.push(entry);
if (this.logs.length > this.maxLogs) {
this.logs.shift();
}
this.notifyListeners(entry);
}
/**
* 通知监听器批处理日志通知以避免在React渲染期间触发状态更新
*/
private notifyListeners(entry: LogEntry): void {
this.pendingNotifications.push(entry);
if (!this.notificationScheduled) {
this.notificationScheduled = true;
queueMicrotask(() => {
const notifications = [...this.pendingNotifications];
this.pendingNotifications = [];
this.notificationScheduled = false;
for (const notification of notifications) {
for (const listener of this.listeners) {
try {
listener(notification);
} catch (error) {
this.originalConsole.error('Error in log listener:', error);
}
}
}
});
}
}
/**
* 获取所有日志
*/
public getLogs(): LogEntry[] {
return [...this.logs];
}
/**
* 清空日志
*/
public clear(): void {
this.logs = [];
}
/**
* 订阅日志更新
*/
public subscribe(listener: LogListener): () => void {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
}
/**
* 设置最大日志数量
*/
public setMaxLogs(max: number): void {
this.maxLogs = max;
while (this.logs.length > this.maxLogs) {
this.logs.shift();
}
}
public dispose(): void {
console.log = this.originalConsole.log;
console.debug = this.originalConsole.debug;
console.info = this.originalConsole.info;
console.warn = this.originalConsole.warn;
console.error = this.originalConsole.error;
this.listeners.clear();
this.logs = [];
}
}

View File

@@ -0,0 +1,336 @@
import type { IService } from '@esengine/ecs-framework';
import { Injectable } from '@esengine/ecs-framework';
import { createLogger } from '@esengine/ecs-framework';
const logger = createLogger('MessageHub');
/**
* 消息处理器类型
*/
export type MessageHandler<T = any> = (data: T) => void | Promise<void>;
/**
* 请求处理器类型(支持返回值)
*/
export type RequestHandler<TRequest = any, TResponse = any> = (data: TRequest) => TResponse | Promise<TResponse>;
/**
* 消息订阅
*/
interface MessageSubscription {
topic: string;
handler: MessageHandler;
once: boolean;
}
/**
* 请求订阅
*/
interface RequestSubscription {
topic: string;
handler: RequestHandler;
}
/**
* 消息总线
*
* 提供插件间的事件通信机制,支持订阅/发布模式。
*/
@Injectable()
export class MessageHub implements IService {
private subscriptions: Map<string, MessageSubscription[]> = new Map();
private requestHandlers: Map<string, RequestSubscription> = new Map();
private subscriptionId: number = 0;
/**
* 订阅消息
*
* @param topic - 消息主题
* @param handler - 消息处理器
* @returns 取消订阅的函数
*/
public subscribe<T = any>(topic: string, handler: MessageHandler<T>): () => void {
return this.addSubscription(topic, handler, false);
}
/**
* 订阅一次性消息
*
* @param topic - 消息主题
* @param handler - 消息处理器
* @returns 取消订阅的函数
*/
public subscribeOnce<T = any>(topic: string, handler: MessageHandler<T>): () => void {
return this.addSubscription(topic, handler, true);
}
/**
* 添加订阅
*/
private addSubscription(topic: string, handler: MessageHandler, once: boolean): () => void {
const subscription: MessageSubscription = {
topic,
handler,
once
};
let subs = this.subscriptions.get(topic);
if (!subs) {
subs = [];
this.subscriptions.set(topic, subs);
}
subs.push(subscription);
const subId = ++this.subscriptionId;
logger.debug(`Subscribed to topic: ${topic} (id: ${subId}, once: ${once})`);
return () => {
this.unsubscribe(topic, subscription);
logger.debug(`Unsubscribed from topic: ${topic} (id: ${subId})`);
};
}
/**
* 取消订阅
*/
private unsubscribe(topic: string, subscription: MessageSubscription): void {
const subs = this.subscriptions.get(topic);
if (!subs) {
return;
}
const index = subs.indexOf(subscription);
if (index !== -1) {
subs.splice(index, 1);
}
if (subs.length === 0) {
this.subscriptions.delete(topic);
}
}
/**
* 发布消息
*
* @param topic - 消息主题
* @param data - 消息数据
*/
public async publish<T = any>(topic: string, data?: T): Promise<void> {
const subs = this.subscriptions.get(topic);
if (!subs || subs.length === 0) {
logger.debug(`No subscribers for topic: ${topic}`);
return;
}
logger.debug(`Publishing message to topic: ${topic} (${subs.length} subscribers)`);
const onceSubscriptions: MessageSubscription[] = [];
for (const sub of subs) {
try {
await sub.handler(data);
if (sub.once) {
onceSubscriptions.push(sub);
}
} catch (error) {
logger.error(`Error in message handler for topic ${topic}:`, error);
}
}
for (const sub of onceSubscriptions) {
this.unsubscribe(topic, sub);
}
}
/**
* 同步发布消息
*
* @param topic - 消息主题
* @param data - 消息数据
*/
public publishSync<T = any>(topic: string, data?: T): void {
const subs = this.subscriptions.get(topic);
if (!subs || subs.length === 0) {
logger.debug(`No subscribers for topic: ${topic}`);
return;
}
logger.debug(`Publishing sync message to topic: ${topic} (${subs.length} subscribers)`);
const onceSubscriptions: MessageSubscription[] = [];
for (const sub of subs) {
try {
const result = sub.handler(data);
if (result instanceof Promise) {
logger.warn(`Async handler used with publishSync for topic: ${topic}`);
}
if (sub.once) {
onceSubscriptions.push(sub);
}
} catch (error) {
logger.error(`Error in message handler for topic ${topic}:`, error);
}
}
for (const sub of onceSubscriptions) {
this.unsubscribe(topic, sub);
}
}
/**
* 取消所有指定主题的订阅
*
* @param topic - 消息主题
*/
public unsubscribeAll(topic: string): void {
const deleted = this.subscriptions.delete(topic);
if (deleted) {
logger.debug(`Unsubscribed all from topic: ${topic}`);
}
}
/**
* 检查主题是否有订阅者
*
* @param topic - 消息主题
* @returns 是否有订阅者
*/
public hasSubscribers(topic: string): boolean {
const subs = this.subscriptions.get(topic);
return subs !== undefined && subs.length > 0;
}
/**
* 获取所有主题
*
* @returns 主题列表
*/
public getTopics(): string[] {
return Array.from(this.subscriptions.keys());
}
/**
* 获取主题的订阅者数量
*
* @param topic - 消息主题
* @returns 订阅者数量
*/
public getSubscriberCount(topic: string): number {
const subs = this.subscriptions.get(topic);
return subs ? subs.length : 0;
}
/**
* 注册请求处理器(用于请求-响应模式)
*
* @param topic - 请求主题
* @param handler - 请求处理器,可以返回响应数据
* @returns 取消注册的函数
*/
public onRequest<TRequest = any, TResponse = any>(
topic: string,
handler: RequestHandler<TRequest, TResponse>
): () => void {
if (this.requestHandlers.has(topic)) {
logger.warn(`Request handler for topic "${topic}" already exists, replacing...`);
}
const subscription: RequestSubscription = {
topic,
handler
};
this.requestHandlers.set(topic, subscription);
logger.debug(`Registered request handler for topic: ${topic}`);
return () => {
if (this.requestHandlers.get(topic) === subscription) {
this.requestHandlers.delete(topic);
logger.debug(`Unregistered request handler for topic: ${topic}`);
}
};
}
/**
* 发送请求并等待响应(请求-响应模式)
*
* @param topic - 请求主题
* @param data - 请求数据
* @param timeout - 超时时间(毫秒),默认 5000ms
* @returns 响应数据
* @throws 如果没有处理器或超时则抛出错误
*/
public async request<TRequest = any, TResponse = any>(
topic: string,
data?: TRequest,
timeout: number = 5000
): Promise<TResponse> {
const subscription = this.requestHandlers.get(topic);
if (!subscription) {
throw new Error(`No request handler registered for topic: ${topic}`);
}
logger.debug(`Sending request to topic: ${topic}`);
const timeoutPromise = new Promise<never>((_, reject) => {
setTimeout(() => {
reject(new Error(`Request to topic "${topic}" timed out after ${timeout}ms`));
}, timeout);
});
const responsePromise = Promise.resolve(subscription.handler(data));
return Promise.race([responsePromise, timeoutPromise]);
}
/**
* 尝试发送请求(如果有处理器则发送,否则返回 undefined
*
* @param topic - 请求主题
* @param data - 请求数据
* @param timeout - 超时时间(毫秒),默认 5000ms
* @returns 响应数据或 undefined
*/
public async tryRequest<TRequest = any, TResponse = any>(
topic: string,
data?: TRequest,
timeout: number = 5000
): Promise<TResponse | undefined> {
if (!this.requestHandlers.has(topic)) {
logger.debug(`No request handler for topic: ${topic}, returning undefined`);
return undefined;
}
try {
return await this.request<TRequest, TResponse>(topic, data, timeout);
} catch (error) {
logger.warn(`Request to topic "${topic}" failed:`, error);
return undefined;
}
}
/**
* 检查是否有请求处理器
*
* @param topic - 请求主题
* @returns 是否有处理器
*/
public hasRequestHandler(topic: string): boolean {
return this.requestHandlers.has(topic);
}
/**
* 释放资源
*/
public dispose(): void {
this.subscriptions.clear();
this.requestHandlers.clear();
logger.info('MessageHub disposed');
}
}
// Service identifier for DI registration (用于跨包插件访问)
// 使用 Symbol.for 确保跨包共享同一个 Symbol
export const IMessageHub = Symbol.for('IMessageHub');

View File

@@ -0,0 +1,626 @@
/**
* Module Registry Service.
* 模块注册表服务。
*
* Manages engine modules, their dependencies, and project configurations.
* 管理引擎模块、其依赖关系和项目配置。
*/
import { createLogger } from '@esengine/ecs-framework';
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 readonly _modules = new Map<string, ModuleRegistryEntry>();
private readonly _logger = createLogger('ModuleRegistry');
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);
this._logger.debug(`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 {
this._logger.warn(`index.json not found at ${indexPath}, run 'pnpm copy-modules' first`);
}
} catch (error) {
this._logger.error('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) {
this._logger.error('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) {
this._logger.error('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) {
this._logger.warn('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) {
this._logger.warn('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,476 @@
/**
* 预制体服务
* Prefab service
*
* 提供预制体实例管理功能:应用修改到源预制体、还原实例、断开链接等。
* Provides prefab instance management: apply to source, revert instance, break link, etc.
*/
import { Injectable, IService, Entity, Core, createLogger } from '@esengine/ecs-framework';
import { PrefabInstanceComponent, PrefabSerializer, PrefabData, ComponentRegistry, ComponentType, HierarchySystem } from '@esengine/ecs-framework';
import { MessageHub } from './MessageHub';
const logger = createLogger('PrefabService');
/**
* 预制体属性覆盖信息
* Prefab property override info
*/
export interface PrefabPropertyOverride {
/** 组件类型名称 | Component type name */
componentType: string;
/** 属性路径 | Property path */
propertyPath: string;
/** 当前值 | Current value */
currentValue: unknown;
/** 原始值(来自源预制体)| Original value (from source prefab) */
originalValue?: unknown;
}
/**
* 文件 API 接口(用于依赖注入)
* File API interface (for dependency injection)
*/
export interface IPrefabFileAPI {
readFileContent(path: string): Promise<string>;
writeFileContent(path: string, content: string): Promise<void>;
pathExists(path: string): Promise<boolean>;
}
/**
* 预制体服务
* Prefab service
*
* 提供预制体实例的管理功能。
* Provides prefab instance management functionality.
*/
@Injectable()
export class PrefabService implements IService {
private fileAPI: IPrefabFileAPI | null = null;
constructor(private messageHub: MessageHub) {}
/**
* 设置文件 API
* Set file API
*
* @param fileAPI - 文件 API 实例 | File API instance
*/
public setFileAPI(fileAPI: IPrefabFileAPI): void {
this.fileAPI = fileAPI;
}
public dispose(): void {
this.fileAPI = null;
}
/**
* 检查实体是否为预制体实例
* Check if entity is a prefab instance
*
* @param entity - 要检查的实体 | Entity to check
* @returns 是否为预制体实例 | Whether it's a prefab instance
*/
public isPrefabInstance(entity: Entity): boolean {
return PrefabSerializer.isPrefabInstance(entity);
}
/**
* 检查实体是否为预制体实例的根节点
* Check if entity is the root of a prefab instance
*
* @param entity - 要检查的实体 | Entity to check
* @returns 是否为根节点 | Whether it's the root
*/
public isPrefabInstanceRoot(entity: Entity): boolean {
const comp = entity.getComponent(PrefabInstanceComponent);
return comp?.isRoot ?? false;
}
/**
* 获取预制体实例组件
* Get prefab instance component
*
* @param entity - 实体 | Entity
* @returns 预制体实例组件,如果不是实例则返回 null | Component or null if not an instance
*/
public getPrefabInstanceComponent(entity: Entity): PrefabInstanceComponent | null {
return entity.getComponent(PrefabInstanceComponent) ?? null;
}
/**
* 获取预制体实例的根实体
* Get root entity of prefab instance
*
* @param entity - 预制体实例中的任意实体 | Any entity in the prefab instance
* @returns 根实体,如果不是实例则返回 null | Root entity or null
*/
public getPrefabInstanceRoot(entity: Entity): Entity | null {
return PrefabSerializer.getPrefabInstanceRoot(entity);
}
/**
* 获取实例相对于源预制体的所有属性覆盖
* Get all property overrides of instance relative to source prefab
*
* @param entity - 预制体实例 | Prefab instance
* @returns 属性覆盖列表 | List of property overrides
*/
public getOverrides(entity: Entity): PrefabPropertyOverride[] {
const comp = entity.getComponent(PrefabInstanceComponent);
if (!comp) return [];
const overrides: PrefabPropertyOverride[] = [];
for (const key of comp.modifiedProperties) {
const [componentType, ...pathParts] = key.split('.');
const propertyPath = pathParts.join('.');
// 获取当前值 | Get current value
let currentValue: unknown = undefined;
for (const compInstance of entity.components) {
const typeName = (compInstance.constructor as any).__componentTypeName || compInstance.constructor.name;
if (typeName === componentType) {
currentValue = this.getNestedValue(compInstance, propertyPath);
break;
}
}
// 获取原始值 | Get original value
const originalValue = comp.getOriginalValue?.(key);
overrides.push({
componentType,
propertyPath,
currentValue,
originalValue
});
}
return overrides;
}
/**
* 检查实例是否有修改
* Check if instance has modifications
*
* @param entity - 预制体实例 | Prefab instance
* @returns 是否有修改 | Whether it has modifications
*/
public hasModifications(entity: Entity): boolean {
const comp = entity.getComponent(PrefabInstanceComponent);
return comp ? comp.modifiedProperties.length > 0 : false;
}
/**
* 获取实例的修改数量
* Get modification count of instance
*
* @param entity - 预制体实例 | Prefab instance
* @returns 修改数量 | Number of modifications
*/
public getModificationCount(entity: Entity): number {
const comp = entity.getComponent(PrefabInstanceComponent);
return comp?.modifiedProperties.length ?? 0;
}
/**
* 将实例的修改应用到源预制体
* Apply instance modifications to source prefab
*
* @param entity - 预制体实例(必须是根节点)| Prefab instance (must be root)
* @returns 是否成功应用 | Whether application was successful
*/
public async applyToPrefab(entity: Entity): Promise<boolean> {
if (!this.fileAPI) {
logger.error('File API not set, cannot apply to prefab');
return false;
}
const comp = entity.getComponent(PrefabInstanceComponent);
if (!comp) {
logger.warn('Entity is not a prefab instance');
return false;
}
if (!comp.isRoot) {
logger.warn('Can only apply from root prefab instance');
return false;
}
const prefabPath = comp.sourcePrefabPath;
if (!prefabPath) {
logger.warn('Source prefab path not found');
return false;
}
try {
// 检查源文件是否存在 | Check if source file exists
const exists = await this.fileAPI.pathExists(prefabPath);
if (!exists) {
logger.error(`Source prefab file not found: ${prefabPath}`);
return false;
}
// 读取原始预制体以获取 GUID | Read original prefab to get GUID
const originalContent = await this.fileAPI.readFileContent(prefabPath);
const originalPrefabData = PrefabSerializer.deserialize(originalContent);
const originalGuid = originalPrefabData.metadata.guid;
// 获取层级系统 | Get hierarchy system
const scene = Core.scene;
const hierarchySystem = scene?.getSystem(HierarchySystem) ?? undefined;
// 从当前实例创建新的预制体数据 | Create new prefab data from current instance
const newPrefabData = PrefabSerializer.createPrefab(
entity,
{
name: originalPrefabData.metadata.name,
description: originalPrefabData.metadata.description,
tags: originalPrefabData.metadata.tags,
includeChildren: true
},
hierarchySystem
);
// 保留原有 GUID 并更新修改时间 | Preserve original GUID and update modified time
newPrefabData.metadata.guid = originalGuid;
newPrefabData.metadata.createdAt = originalPrefabData.metadata.createdAt;
newPrefabData.metadata.modifiedAt = Date.now();
// 序列化并保存 | Serialize and save
const json = PrefabSerializer.serialize(newPrefabData, true);
await this.fileAPI.writeFileContent(prefabPath, json);
// 清除修改记录 | Clear modification records
comp.clearAllModifications();
logger.info(`Applied changes to prefab: ${prefabPath}`);
// 发布事件 | Publish event
await this.messageHub.publish('prefab:applied', {
entityId: entity.id,
prefabPath,
prefabGuid: originalGuid
});
return true;
} catch (error) {
logger.error('Failed to apply to prefab:', error);
return false;
}
}
/**
* 将实例还原为源预制体的状态
* Revert instance to source prefab state
*
* @param entity - 预制体实例(必须是根节点)| Prefab instance (must be root)
* @returns 是否成功还原 | Whether revert was successful
*/
public async revertInstance(entity: Entity): Promise<boolean> {
if (!this.fileAPI) {
logger.error('File API not set, cannot revert instance');
return false;
}
const comp = entity.getComponent(PrefabInstanceComponent);
if (!comp) {
logger.warn('Entity is not a prefab instance');
return false;
}
if (!comp.isRoot) {
logger.warn('Can only revert root prefab instance');
return false;
}
const prefabPath = comp.sourcePrefabPath;
if (!prefabPath) {
logger.warn('Source prefab path not found');
return false;
}
try {
// 读取源预制体 | Read source prefab
const content = await this.fileAPI.readFileContent(prefabPath);
const prefabData = PrefabSerializer.deserialize(content);
// 还原所有修改的属性 | Revert all modified properties
for (const key of [...comp.modifiedProperties]) {
const [componentType, ...pathParts] = key.split('.');
const propertyPath = pathParts.join('.');
// 从 originalValues 获取原始值 | Get original value from originalValues
const originalValue = comp.getOriginalValue?.(key);
if (originalValue !== undefined) {
// 应用原始值到组件 | Apply original value to component
for (const compInstance of entity.components) {
const typeName = (compInstance.constructor as any).__componentTypeName || compInstance.constructor.name;
if (typeName === componentType) {
this.setNestedValue(compInstance, propertyPath, originalValue);
break;
}
}
}
}
// 清除修改记录 | Clear modification records
comp.clearAllModifications();
logger.info(`Reverted prefab instance: ${entity.name}`);
// 发布事件 | Publish event
await this.messageHub.publish('prefab:reverted', {
entityId: entity.id,
prefabPath,
prefabGuid: comp.sourcePrefabGuid
});
// 发布组件变更事件以刷新 UI | Publish component change event to refresh UI
await this.messageHub.publish('component:property:changed', {
entityId: entity.id
});
return true;
} catch (error) {
logger.error('Failed to revert instance:', error);
return false;
}
}
/**
* 还原单个属性到源预制体的值
* Revert single property to source prefab value
*
* @param entity - 预制体实例 | Prefab instance
* @param componentType - 组件类型名称 | Component type name
* @param propertyPath - 属性路径 | Property path
* @returns 是否成功还原 | Whether revert was successful
*/
public async revertProperty(entity: Entity, componentType: string, propertyPath: string): Promise<boolean> {
const comp = entity.getComponent(PrefabInstanceComponent);
if (!comp) {
logger.warn('Entity is not a prefab instance');
return false;
}
const key = `${componentType}.${propertyPath}`;
// 从 originalValues 获取原始值 | Get original value from originalValues
const originalValue = comp.getOriginalValue?.(key);
if (originalValue === undefined) {
logger.warn(`No original value found for ${key}`);
return false;
}
// 应用原始值到组件 | Apply original value to component
for (const compInstance of entity.components) {
const typeName = (compInstance.constructor as any).__componentTypeName || compInstance.constructor.name;
if (typeName === componentType) {
this.setNestedValue(compInstance, propertyPath, originalValue);
// 清除该属性的修改标记 | Clear modification mark for this property
comp.clearPropertyModified(componentType, propertyPath);
logger.debug(`Reverted property ${key} to original value`);
// 发布事件 | Publish event
await this.messageHub.publish('prefab:property:reverted', {
entityId: entity.id,
componentType,
propertyPath
});
// 发布组件变更事件以刷新 UI | Publish component change event to refresh UI
await this.messageHub.publish('component:property:changed', {
entityId: entity.id,
componentType,
propertyPath
});
return true;
}
}
logger.warn(`Component ${componentType} not found on entity`);
return false;
}
/**
* 断开预制体链接
* Break prefab link
*
* 移除实体的预制体实例组件,使其成为普通实体。
* Removes the prefab instance component, making it a regular entity.
*
* @param entity - 预制体实例 | Prefab instance
*/
public breakPrefabLink(entity: Entity): void {
const comp = entity.getComponent(PrefabInstanceComponent);
if (!comp) {
logger.warn('Entity is not a prefab instance');
return;
}
const wasRoot = comp.isRoot;
const prefabGuid = comp.sourcePrefabGuid;
const prefabPath = comp.sourcePrefabPath;
// 移除预制体实例组件 | Remove prefab instance component
entity.removeComponentByType(PrefabInstanceComponent);
// 如果是根节点,也要移除所有子实体的预制体实例组件
// If it's root, also remove prefab instance components from all children
if (wasRoot) {
const scene = Core.scene;
if (scene) {
scene.entities.forEach((e) => {
const childComp = e.getComponent(PrefabInstanceComponent);
if (childComp && childComp.rootInstanceEntityId === entity.id) {
e.removeComponentByType(PrefabInstanceComponent);
}
});
}
}
logger.info(`Broke prefab link for entity: ${entity.name}`);
// 发布事件 | Publish event
this.messageHub.publish('prefab:link:broken', {
entityId: entity.id,
wasRoot,
prefabGuid,
prefabPath
});
}
/**
* 获取嵌套属性值
* Get nested property value
*/
private getNestedValue(obj: any, path: string): unknown {
const parts = path.split('.');
let current = obj;
for (const part of parts) {
if (current === null || current === undefined) return undefined;
current = current[part];
}
return current;
}
/**
* 设置嵌套属性值
* Set nested property value
*/
private setNestedValue(obj: any, path: string, value: unknown): void {
const parts = path.split('.');
let current = obj;
for (let i = 0; i < parts.length - 1; i++) {
if (current[parts[i]] === null || current[parts[i]] === undefined) {
current[parts[i]] = {};
}
current = current[parts[i]];
}
current[parts[parts.length - 1]] = value;
}
}

View File

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

@@ -0,0 +1,593 @@
import type { IService } from '@esengine/ecs-framework';
import { Injectable } from '@esengine/ecs-framework';
import { createLogger, Scene } from '@esengine/ecs-framework';
import { MessageHub } from './MessageHub';
import { SceneTemplateRegistry } from './SceneTemplateRegistry';
import type { IFileAPI } from '../Types/IFileAPI';
const logger = createLogger('ProjectService');
export type ProjectType = 'esengine' | 'unknown';
export interface ProjectInfo {
path: string;
type: ProjectType;
name: string;
configPath?: string;
}
/**
* UI 设计分辨率配置
* UI Design Resolution Configuration
*/
export interface UIDesignResolution {
/** 设计宽度 / Design width */
width: number;
/** 设计高度 / Design height */
height: number;
}
/**
* 插件配置
* Plugin Configuration
*/
export interface PluginSettings {
/** 启用的插件 ID 列表 / Enabled plugin IDs */
enabledPlugins: string[];
}
/**
* 模块配置
* Module Configuration
*/
export interface ModuleSettings {
/**
* 禁用的模块 ID 列表(黑名单方式)
* Disabled module IDs (blacklist approach)
* Modules NOT in this list are enabled.
* 不在此列表中的模块为启用状态。
*/
disabledModules: string[];
}
/**
* 构建配置
* Build Settings Configuration
*
* Persisted build settings for the project.
* 项目的持久化构建设置。
*/
export interface BuildSettingsConfig {
/** Selected scenes for build | 构建选中的场景 */
scenes?: string[];
/** Scripting defines | 脚本定义 */
scriptingDefines?: string[];
/** Company name | 公司名 */
companyName?: string;
/** Product name | 产品名 */
productName?: string;
/** Version | 版本号 */
version?: string;
/** Development build | 开发构建 */
developmentBuild?: boolean;
/** Source map | 源码映射 */
sourceMap?: boolean;
/** Compression method | 压缩方式 */
compressionMethod?: 'Default' | 'LZ4' | 'LZ4HC';
/** Web build mode | Web 构建模式 */
buildMode?: 'split-bundles' | 'single-bundle' | 'single-file';
}
export interface ProjectConfig {
projectType?: ProjectType;
/** User scripts directory (default: 'scripts') | 用户脚本目录(默认:'scripts' */
scriptsPath?: string;
/** Build output directory | 构建输出目录 */
buildOutput?: string;
/** Scenes directory | 场景目录 */
scenesPath?: string;
/** Default scene file | 默认场景文件 */
defaultScene?: string;
/** UI design resolution | UI 设计分辨率 */
uiDesignResolution?: UIDesignResolution;
/** Plugin settings | 插件配置 */
plugins?: PluginSettings;
/** Module settings | 模块配置 */
modules?: ModuleSettings;
/** Build settings | 构建配置 */
buildSettings?: BuildSettingsConfig;
}
@Injectable()
export class ProjectService implements IService {
private currentProject: ProjectInfo | null = null;
private projectConfig: ProjectConfig | null = null;
private messageHub: MessageHub;
private fileAPI: IFileAPI;
constructor(messageHub: MessageHub, fileAPI: IFileAPI) {
this.messageHub = messageHub;
this.fileAPI = fileAPI;
}
public async createProject(projectPath: string): Promise<void> {
try {
const sep = projectPath.includes('\\') ? '\\' : '/';
const configPath = `${projectPath}${sep}ecs-editor.config.json`;
const configExists = await this.fileAPI.pathExists(configPath);
if (configExists) {
throw new Error('ECS project already exists in this directory');
}
const config: ProjectConfig = {
projectType: 'esengine',
scriptsPath: 'scripts',
buildOutput: '.esengine/compiled',
scenesPath: 'scenes',
defaultScene: 'main.ecs',
plugins: { enabledPlugins: [] },
modules: { disabledModules: [] }
};
await this.fileAPI.writeFileContent(configPath, JSON.stringify(config, null, 2));
// Create scenes folder and default scene
// 创建场景文件夹和默认场景
const scenesPath = `${projectPath}${sep}${config.scenesPath}`;
await this.fileAPI.createDirectory(scenesPath);
const defaultScenePath = `${scenesPath}${sep}${config.defaultScene}`;
const defaultScene = new Scene();
// 使用场景模板注册表创建默认实体(如相机)
// Use scene template registry to create default entities (e.g., camera)
SceneTemplateRegistry.createDefaultEntities(defaultScene);
const sceneData = defaultScene.serialize({
format: 'json',
pretty: true,
includeMetadata: true
}) as string;
await this.fileAPI.writeFileContent(defaultScenePath, sceneData);
// Create scripts folder for user scripts
// 创建用户脚本文件夹
const scriptsPath = `${projectPath}${sep}${config.scriptsPath}`;
await this.fileAPI.createDirectory(scriptsPath);
// Create scripts/editor folder for editor extension scripts
// 创建编辑器扩展脚本文件夹
const editorScriptsPath = `${scriptsPath}${sep}editor`;
await this.fileAPI.createDirectory(editorScriptsPath);
// Create assets folder for project assets (textures, audio, etc.)
// 创建资源文件夹(纹理、音频等)
const assetsPath = `${projectPath}${sep}assets`;
await this.fileAPI.createDirectory(assetsPath);
// Create tsconfig.json for runtime scripts (components, systems)
// 创建运行时脚本的 tsconfig.json组件、系统等
// Note: paths will be populated by update_project_tsconfig when project is opened
// 注意paths 会在项目打开时由 update_project_tsconfig 填充
const tsConfig = {
compilerOptions: {
target: 'ES2020',
module: 'ESNext',
moduleResolution: 'bundler',
lib: ['ES2020', 'DOM'],
strict: true,
esModuleInterop: true,
skipLibCheck: true,
forceConsistentCasingInFileNames: true,
experimentalDecorators: true,
emitDecoratorMetadata: true,
noEmit: true
// paths will be added by editor when project is opened
// paths 会在编辑器打开项目时添加
},
include: ['scripts/**/*.ts'],
exclude: ['scripts/editor/**/*.ts', '.esengine']
};
const tsConfigPath = `${projectPath}${sep}tsconfig.json`;
await this.fileAPI.writeFileContent(tsConfigPath, JSON.stringify(tsConfig, null, 2));
// Create tsconfig.editor.json for editor extension scripts
// 创建编辑器扩展脚本的 tsconfig.editor.json
const tsConfigEditor = {
compilerOptions: {
target: 'ES2020',
module: 'ESNext',
moduleResolution: 'bundler',
lib: ['ES2020', 'DOM'],
strict: true,
esModuleInterop: true,
skipLibCheck: true,
forceConsistentCasingInFileNames: true,
experimentalDecorators: true,
emitDecoratorMetadata: true,
noEmit: true
// paths will be added by editor when project is opened
// paths 会在编辑器打开项目时添加
},
include: ['scripts/editor/**/*.ts'],
exclude: ['.esengine']
};
const tsConfigEditorPath = `${projectPath}${sep}tsconfig.editor.json`;
await this.fileAPI.writeFileContent(tsConfigEditorPath, JSON.stringify(tsConfigEditor, null, 2));
await this.messageHub.publish('project:created', {
path: projectPath
});
logger.info('Project created', { path: projectPath });
} catch (error) {
logger.error('Failed to create project', error);
throw error;
}
}
public async openProject(projectPath: string): Promise<void> {
try {
const projectInfo = await this.validateProject(projectPath);
this.currentProject = projectInfo;
if (projectInfo.configPath) {
this.projectConfig = await this.loadConfig(projectInfo.configPath);
}
await this.messageHub.publish('project:opened', {
path: projectPath,
type: projectInfo.type,
name: projectInfo.name
});
logger.info('Project opened', { path: projectPath, type: projectInfo.type });
} catch (error) {
logger.error('Failed to open project', error);
throw error;
}
}
public async closeProject(): Promise<void> {
if (!this.currentProject) {
logger.warn('No project is currently open');
return;
}
const projectPath = this.currentProject.path;
this.currentProject = null;
this.projectConfig = null;
await this.messageHub.publish('project:closed', { path: projectPath });
logger.info('Project closed', { path: projectPath });
}
public getCurrentProject(): ProjectInfo | null {
return this.currentProject;
}
public getProjectConfig(): ProjectConfig | null {
return this.projectConfig;
}
public isProjectOpen(): boolean {
return this.currentProject !== null;
}
/**
* Get user scripts directory path.
* 获取用户脚本目录路径。
*
* @returns Scripts directory path | 脚本目录路径
*/
public getScriptsPath(): string | null {
if (!this.currentProject) {
return null;
}
const scriptsPath = this.projectConfig?.scriptsPath || 'scripts';
const sep = this.currentProject.path.includes('\\') ? '\\' : '/';
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 {
if (!this.currentProject) {
return null;
}
const scenesPath = this.projectConfig?.scenesPath || 'assets/scenes';
const sep = this.currentProject.path.includes('\\') ? '\\' : '/';
return `${this.currentProject.path}${sep}${scenesPath}`;
}
public getDefaultScenePath(): string | null {
if (!this.currentProject) {
return null;
}
const scenesPath = this.getScenesPath();
if (!scenesPath) {
return null;
}
const defaultScene = this.projectConfig?.defaultScene || 'main.scene';
const sep = this.currentProject.path.includes('\\') ? '\\' : '/';
return `${scenesPath}${sep}${defaultScene}`;
}
private async validateProject(projectPath: string): Promise<ProjectInfo> {
const projectName = projectPath.split(/[\\/]/).pop() || 'Unknown Project';
const projectInfo: ProjectInfo = {
path: projectPath,
type: 'unknown',
name: projectName
};
const sep = projectPath.includes('\\') ? '\\' : '/';
const configPath = `${projectPath}${sep}ecs-editor.config.json`;
try {
projectInfo.configPath = configPath;
projectInfo.type = 'esengine';
} catch (error) {
logger.warn('No ecs-editor.config.json found, using defaults');
}
return projectInfo;
}
private async loadConfig(configPath: string): Promise<ProjectConfig> {
try {
const content = await this.fileAPI.readFileContent(configPath);
logger.debug('Raw config content:', content);
const config = JSON.parse(content) as ProjectConfig;
logger.debug('Parsed config plugins:', config.plugins);
const result: ProjectConfig = {
projectType: config.projectType || 'esengine',
scriptsPath: config.scriptsPath || 'scripts',
buildOutput: config.buildOutput || '.esengine/compiled',
scenesPath: config.scenesPath || 'scenes',
defaultScene: config.defaultScene || 'main.ecs',
uiDesignResolution: config.uiDesignResolution,
// Provide default empty plugins config for legacy projects
// 为旧项目提供默认的空插件配置
plugins: config.plugins || { enabledPlugins: [] },
modules: config.modules || { disabledModules: [] }
};
logger.debug('Loaded config result:', result);
return result;
} catch (error) {
logger.warn('Failed to load config, using defaults', error);
return {
projectType: 'esengine',
scriptsPath: 'scripts',
buildOutput: '.esengine/compiled',
scenesPath: 'scenes',
defaultScene: 'main.ecs'
};
}
}
/**
* 保存项目配置
*/
public async saveConfig(): Promise<void> {
if (!this.currentProject?.configPath || !this.projectConfig) {
logger.warn('No project or config to save');
return;
}
try {
const content = JSON.stringify(this.projectConfig, null, 2);
await this.fileAPI.writeFileContent(this.currentProject.configPath, content);
logger.info('Project config saved');
} catch (error) {
logger.error('Failed to save project config', error);
throw error;
}
}
/**
* 更新项目配置
*/
public async updateConfig(updates: Partial<ProjectConfig>): Promise<void> {
if (!this.projectConfig) {
logger.warn('No project config to update');
return;
}
this.projectConfig = {
...this.projectConfig,
...updates
};
await this.saveConfig();
await this.messageHub.publish('project:configUpdated', { config: this.projectConfig });
}
/**
* 获取 UI 设计分辨率
* Get UI design resolution
*
* @returns UI design resolution, defaults to 1920x1080 if not set
*/
public getUIDesignResolution(): UIDesignResolution {
return this.projectConfig?.uiDesignResolution || { width: 1920, height: 1080 };
}
/**
* 设置 UI 设计分辨率
* Set UI design resolution
*
* @param resolution - The new design resolution
*/
public async setUIDesignResolution(resolution: UIDesignResolution): Promise<void> {
await this.updateConfig({ uiDesignResolution: resolution });
}
/**
* 获取启用的插件列表
* Get enabled plugins list
*/
public getEnabledPlugins(): string[] {
return this.projectConfig?.plugins?.enabledPlugins || [];
}
/**
* 获取插件配置
* Get plugin settings
*/
public getPluginSettings(): PluginSettings | null {
logger.debug('getPluginSettings called, projectConfig:', this.projectConfig);
logger.debug('getPluginSettings plugins:', this.projectConfig?.plugins);
return this.projectConfig?.plugins || null;
}
/**
* 设置启用的插件列表
* Set enabled plugins list
*
* @param enabledPlugins - Array of enabled plugin IDs
*/
public async setEnabledPlugins(enabledPlugins: string[]): Promise<void> {
await this.updateConfig({
plugins: {
enabledPlugins
}
});
await this.messageHub.publish('project:pluginsChanged', { enabledPlugins });
logger.info('Plugin settings saved', { count: enabledPlugins.length });
}
/**
* 启用插件
* Enable a plugin
*/
public async enablePlugin(pluginId: string): Promise<void> {
const current = this.getEnabledPlugins();
if (!current.includes(pluginId)) {
await this.setEnabledPlugins([...current, pluginId]);
}
}
/**
* 禁用插件
* Disable a plugin
*/
public async disablePlugin(pluginId: string): Promise<void> {
const current = this.getEnabledPlugins();
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);
}
// ==================== Build Settings ====================
/**
* 获取构建设置
* Get build settings
*/
public getBuildSettings(): BuildSettingsConfig | null {
return this.projectConfig?.buildSettings || null;
}
/**
* 更新构建设置
* Update build settings
*
* @param settings - Build settings to update (partial)
*/
public async updateBuildSettings(settings: Partial<BuildSettingsConfig>): Promise<void> {
const current = this.projectConfig?.buildSettings || {};
await this.updateConfig({
buildSettings: {
...current,
...settings
}
});
await this.messageHub.publish('project:buildSettingsChanged', { settings });
logger.info('Build settings saved');
}
public dispose(): void {
this.currentProject = null;
this.projectConfig = null;
logger.info('ProjectService disposed');
}
}

View File

@@ -0,0 +1,69 @@
import type { IService, PropertyOptions, PropertyAction, PropertyControl, PropertyAssetType, EnumOption, PropertyType } from '@esengine/ecs-framework';
import { Injectable, Component, getPropertyMetadata, getComponentInstanceTypeName, isComponentInstanceHiddenInInspector } from '@esengine/ecs-framework';
import { createLogger } from '@esengine/ecs-framework';
const logger = createLogger('PropertyMetadata');
export type { PropertyOptions, PropertyAction, PropertyControl, PropertyAssetType, EnumOption, PropertyType };
export type PropertyMetadata = PropertyOptions;
export interface ComponentMetadata {
properties: Record<string, PropertyMetadata>;
}
/**
* 组件属性元数据服务
*
* 管理组件属性的元数据信息,用于动态生成属性编辑器
*/
@Injectable()
export class PropertyMetadataService implements IService {
private metadata: Map<new (...args: any[]) => Component, ComponentMetadata> = new Map();
/**
* 注册组件元数据
*/
public register(componentType: new (...args: any[]) => Component, metadata: ComponentMetadata): void {
this.metadata.set(componentType, metadata);
logger.debug(`Registered metadata for component: ${componentType.name}`);
}
/**
* 获取组件元数据
*/
public getMetadata(componentType: new (...args: any[]) => Component): ComponentMetadata | undefined {
return this.metadata.get(componentType);
}
/**
* 获取组件的所有可编辑属性
* Get all editable properties of a component
*/
public getEditableProperties(component: Component): Record<string, PropertyMetadata> {
// 优先使用手动注册的元数据
const registeredMetadata = this.metadata.get(component.constructor as new (...args: any[]) => Component);
if (registeredMetadata) {
return registeredMetadata.properties;
}
// 然后尝试从装饰器获取元数据
const decoratorMetadata = getPropertyMetadata(component.constructor);
if (decoratorMetadata) {
return decoratorMetadata as Record<string, PropertyMetadata>;
}
// 没有元数据时返回空对象
// 使用 @ECSComponent 装饰器的 editor.hideInInspector 选项判断是否为内部组件
// Use @ECSComponent decorator's editor.hideInInspector option to check if internal component
if (!isComponentInstanceHiddenInInspector(component)) {
const componentTypeName = getComponentInstanceTypeName(component);
logger.warn(`No property metadata found for component: ${componentTypeName}`);
}
return {};
}
public dispose(): void {
this.metadata.clear();
logger.info('PropertyMetadataService disposed');
}
}

View File

@@ -0,0 +1,87 @@
/**
* @zh 属性渲染器注册表
* @en Property Renderer Registry
*/
import React from 'react';
import { PrioritizedRegistry, createRegistryToken } from './BaseRegistry';
import type { IPropertyRenderer, IPropertyRendererRegistry, PropertyContext } from './IPropertyRenderer';
/**
* @zh 属性渲染器注册表
* @en Property Renderer Registry
*/
export class PropertyRendererRegistry
extends PrioritizedRegistry<IPropertyRenderer>
implements IPropertyRendererRegistry {
constructor() {
super('PropertyRendererRegistry');
}
protected getItemKey(item: IPropertyRenderer): string {
return item.id;
}
protected override getItemDisplayName(item: IPropertyRenderer): string {
return `${item.name} (${item.id})`;
}
/**
* @zh 查找渲染器
* @en Find renderer
*/
findRenderer(value: unknown, context: PropertyContext): IPropertyRenderer | undefined {
return this.findByPriority(renderer => {
try {
return renderer.canHandle(value, context);
} catch (error) {
this._logger.error(`Error in canHandle for ${renderer.id}:`, error);
return false;
}
});
}
/**
* @zh 渲染属性
* @en Render property
*/
render(value: unknown, context: PropertyContext): React.ReactElement | null {
const renderer = this.findRenderer(value, context);
if (!renderer) {
this._logger.debug(`No renderer found for value type: ${typeof value}`);
return null;
}
try {
return renderer.render(value, context);
} catch (error) {
this._logger.error(`Error rendering with ${renderer.id}:`, error);
return React.createElement(
'span',
{ style: { color: '#f87171', fontStyle: 'italic' } },
'[Render Error]'
);
}
}
/**
* @zh 获取所有渲染器
* @en Get all renderers
*/
getAllRenderers(): IPropertyRenderer[] {
return this.getAll();
}
/**
* @zh 检查是否有可用渲染器
* @en Check if renderer is available
*/
hasRenderer(value: unknown, context: PropertyContext): boolean {
return this.findRenderer(value, context) !== undefined;
}
}
/** @zh 属性渲染器注册表服务标识符 @en Property renderer registry service identifier */
export const PropertyRendererRegistryToken = createRegistryToken<PropertyRendererRegistry>('PropertyRendererRegistry');

View File

@@ -0,0 +1,887 @@
import type { IService, Entity, PrefabData } from '@esengine/ecs-framework';
import {
Injectable,
Core,
createLogger,
SceneSerializer,
Scene,
PrefabSerializer,
HierarchySystem,
GlobalComponentRegistry
} from '@esengine/ecs-framework';
import type { ComponentType } from '@esengine/ecs-framework';
import type { SceneResourceManager } from '@esengine/asset-system';
import type { MessageHub } from './MessageHub';
import type { IFileAPI } from '../Types/IFileAPI';
import type { ProjectService } from './ProjectService';
import type { EntityStoreService } from './EntityStoreService';
import { SceneTemplateRegistry } from './SceneTemplateRegistry';
const logger = createLogger('SceneManagerService');
export interface SceneState {
currentScenePath: string | null;
sceneName: string;
isModified: boolean;
isSaved: boolean;
/** 文件最后已知的修改时间(毫秒)| Last known file modification time (ms) */
lastKnownMtime: number | null;
/** 文件是否被外部修改 | Whether file was modified externally */
externallyModified: boolean;
}
/**
* 预制体编辑模式状态
* Prefab edit mode state
*/
export interface PrefabEditModeState {
/** 是否处于预制体编辑模式 | Whether in prefab edit mode */
isActive: boolean;
/** 预制体文件路径 | Prefab file path */
prefabPath: string;
/** 预制体名称 | Prefab name */
prefabName: string;
/** 预制体 GUID | Prefab GUID */
prefabGuid?: string;
/** 原始预制体数据(用于比较修改) | Original prefab data (for modification comparison) */
originalPrefabData: PrefabData;
/** 原场景路径 | Original scene path */
originalScenePath: string | null;
/** 原场景名称 | Original scene name */
originalSceneName: string;
/** 原场景是否已修改 | Whether original scene was modified */
originalSceneModified: boolean;
}
@Injectable()
export class SceneManagerService implements IService {
private sceneState: SceneState = {
currentScenePath: null,
sceneName: 'Untitled',
isModified: false,
isSaved: false,
lastKnownMtime: null,
externallyModified: false
};
/** 预制体编辑模式状态 | Prefab edit mode state */
private prefabEditModeState: PrefabEditModeState | null = null;
/** 预制体编辑时场景中的根实体 | Root entity in scene during prefab editing */
private prefabRootEntity: Entity | null = null;
private unsubscribeHandlers: Array<() => void> = [];
private sceneResourceManager: SceneResourceManager | null = null;
constructor(
private messageHub: MessageHub,
private fileAPI: IFileAPI,
private projectService?: ProjectService,
private entityStore?: EntityStoreService
) {
this.setupAutoModificationTracking();
logger.info('SceneManagerService initialized');
}
/**
* 设置场景资源管理器
* Set scene resource manager
*/
public setSceneResourceManager(manager: SceneResourceManager | null): void {
this.sceneResourceManager = manager;
}
/**
* 创建新场景
* Create a new scene
*
* @param templateName - 场景模板名称,不传则使用默认模板 / Scene template name, uses default if not specified
*/
public async newScene(templateName?: string): Promise<void> {
if (!await this.canClose()) {
return;
}
const scene = Core.scene as Scene | null;
if (!scene) {
throw new Error('No active scene');
}
// 确保编辑器模式下设置 isEditorMode延迟组件生命周期回调
// Ensure isEditorMode is set in editor to defer component lifecycle callbacks
scene.isEditorMode = true;
// 只移除实体,保留系统(系统由模块管理)
// Only remove entities, preserve systems (systems managed by modules)
scene.entities.removeAllEntities();
// 使用场景模板创建默认实体
// Create default entities using scene template
const createdEntities = SceneTemplateRegistry.createDefaultEntities(scene, templateName);
logger.debug(`Created ${createdEntities.length} default entities from template`);
this.sceneState = {
currentScenePath: null,
sceneName: 'Untitled',
isModified: false,
isSaved: false,
lastKnownMtime: null,
externallyModified: false
};
// 同步到 EntityStore
// Sync to EntityStore
this.entityStore?.syncFromScene();
// 通知创建的实体
// Notify about created entities
for (const entity of createdEntities) {
await this.messageHub.publish('entity:added', { entity });
}
await this.messageHub.publish('scene:new', {});
logger.info('New scene created');
}
public async openScene(filePath?: string): Promise<void> {
if (!await this.canClose()) {
return;
}
let path: string | null | undefined = filePath;
if (!path) {
path = await this.fileAPI.openSceneDialog();
if (!path) {
return;
}
}
// 在加载新场景前,清理旧场景的纹理映射(释放 GPU 资源)
// Before loading new scene, clear old scene's texture mappings (release GPU resources)
// 注意:路径稳定 ID 缓存 (_pathIdCache) 不会被清除
// Note: Path-stable ID cache (_pathIdCache) is NOT cleared
if (this.sceneResourceManager) {
const oldScene = Core.scene as Scene | null;
if (oldScene && this.sceneState.currentScenePath) {
logger.info(`[openScene] Unloading old scene resources from: ${this.sceneState.currentScenePath}`);
await this.sceneResourceManager.unloadSceneResources(oldScene);
}
}
try {
const jsonData = await this.fileAPI.readFileContent(path);
const validation = SceneSerializer.validate(jsonData);
if (!validation.valid) {
throw new Error(`场景文件损坏: ${validation.errors?.join(', ')}`);
}
const scene = Core.scene as Scene | null;
if (!scene) {
throw new Error('No active scene');
}
// 确保编辑器模式下设置 isEditorMode延迟组件生命周期回调
// Ensure isEditorMode is set in editor to defer component lifecycle callbacks
scene.isEditorMode = true;
// 调试:检查缺失的组件类型 | Debug: check missing component types
const registeredComponents = GlobalComponentRegistry.getAllComponentNames();
try {
const sceneData = JSON.parse(jsonData);
const requiredTypes = new Set<string>();
for (const entity of sceneData.entities || []) {
for (const comp of entity.components || []) {
requiredTypes.add(comp.type);
}
}
// 检查缺失的组件类型 | Check missing component types
const missingTypes = Array.from(requiredTypes).filter(t => !registeredComponents.has(t));
if (missingTypes.length > 0) {
logger.warn(`[SceneManagerService.openScene] Missing component types (scene will load without these):`, missingTypes);
logger.debug(`Registered components (${registeredComponents.size}):`, Array.from(registeredComponents.keys()));
}
} catch (e) {
// JSON parsing should not fail at this point since we validated earlier
}
// 调试:反序列化前场景状态 | Debug: scene state before deserialize
logger.info(`[openScene] Before deserialize: entities.count = ${scene.entities.count}`);
scene.deserialize(jsonData, {
strategy: 'replace'
});
// 调试:反序列化后场景状态 | Debug: scene state after deserialize
logger.info(`[openScene] After deserialize: entities.count = ${scene.entities.count}`);
if (scene.entities.count > 0) {
const entityNames: string[] = [];
scene.entities.forEach(e => entityNames.push(e.name));
logger.info(`[openScene] Entity names: ${entityNames.join(', ')}`);
}
// 加载场景资源 / Load scene resources
if (this.sceneResourceManager) {
await this.sceneResourceManager.loadSceneResources(scene);
} else {
logger.warn('[SceneManagerService] SceneResourceManager not available, skipping resource loading');
}
const fileName = path.split(/[/\\]/).pop() || 'Untitled';
const sceneName = fileName.replace('.ecs', '');
// 获取文件修改时间 | Get file modification time
let mtime: number | null = null;
if (this.fileAPI.getFileMtime) {
try {
mtime = await this.fileAPI.getFileMtime(path);
} catch (e) {
logger.warn('Failed to get file mtime:', e);
}
}
this.sceneState = {
currentScenePath: path,
sceneName,
isModified: false,
isSaved: true,
lastKnownMtime: mtime,
externallyModified: false
};
this.entityStore?.syncFromScene();
await this.messageHub.publish('scene:loaded', {
path,
sceneName,
isModified: false,
isSaved: true
});
logger.info(`Scene loaded: ${path}`);
} catch (error) {
logger.error('Failed to load scene:', error);
throw error;
}
}
public async saveScene(force: boolean = false): Promise<void> {
if (!this.sceneState.currentScenePath) {
await this.saveSceneAs();
return;
}
// 检查文件是否被外部修改 | Check if file was modified externally
if (!force && await this.checkExternalModification()) {
// 发布事件让 UI 显示确认对话框 | Publish event for UI to show confirmation dialog
await this.messageHub.publish('scene:externalModification', {
path: this.sceneState.currentScenePath,
sceneName: this.sceneState.sceneName
});
return; // 等待用户确认 | Wait for user confirmation
}
try {
const scene = Core.scene as Scene | null;
if (!scene) {
throw new Error('No active scene');
}
const jsonData = scene.serialize({
format: 'json',
pretty: true,
includeMetadata: true
}) as string;
await this.fileAPI.saveProject(this.sceneState.currentScenePath, jsonData);
// 更新 mtime | Update mtime
if (this.fileAPI.getFileMtime) {
try {
this.sceneState.lastKnownMtime = await this.fileAPI.getFileMtime(this.sceneState.currentScenePath);
} catch (e) {
logger.warn('Failed to update file mtime after save:', e);
}
}
this.sceneState.isModified = false;
this.sceneState.isSaved = true;
this.sceneState.externallyModified = false;
await this.messageHub.publish('scene:saved', {
path: this.sceneState.currentScenePath
});
logger.info(`Scene saved: ${this.sceneState.currentScenePath}`);
} catch (error) {
logger.error('Failed to save scene:', error);
throw error;
}
}
/**
* 检查场景文件是否被外部修改
* Check if scene file was modified externally
*
* @returns true 如果文件被外部修改 | true if file was modified externally
*/
public async checkExternalModification(): Promise<boolean> {
const path = this.sceneState.currentScenePath;
const lastMtime = this.sceneState.lastKnownMtime;
if (!path || lastMtime === null || !this.fileAPI.getFileMtime) {
return false;
}
try {
const currentMtime = await this.fileAPI.getFileMtime(path);
const isModified = currentMtime > lastMtime;
if (isModified) {
this.sceneState.externallyModified = true;
logger.warn(`Scene file externally modified: ${path} (${lastMtime} -> ${currentMtime})`);
}
return isModified;
} catch (e) {
logger.warn('Failed to check file mtime:', e);
return false;
}
}
/**
* 重新加载当前场景(放弃本地更改)
* Reload current scene (discard local changes)
*/
public async reloadScene(): Promise<void> {
const path = this.sceneState.currentScenePath;
if (!path) {
logger.warn('No scene to reload');
return;
}
// 强制打开场景,绕过修改检查 | Force open scene, bypass modification check
const scene = Core.scene as Scene | null;
if (!scene) {
throw new Error('No active scene');
}
try {
const jsonData = await this.fileAPI.readFileContent(path);
const validation = SceneSerializer.validate(jsonData);
if (!validation.valid) {
throw new Error(`场景文件损坏: ${validation.errors?.join(', ')}`);
}
scene.isEditorMode = true;
scene.deserialize(jsonData, { strategy: 'replace' });
if (this.sceneResourceManager) {
await this.sceneResourceManager.loadSceneResources(scene);
}
// 更新 mtime | Update mtime
if (this.fileAPI.getFileMtime) {
try {
this.sceneState.lastKnownMtime = await this.fileAPI.getFileMtime(path);
} catch (e) {
logger.warn('Failed to update file mtime after reload:', e);
}
}
this.sceneState.isModified = false;
this.sceneState.isSaved = true;
this.sceneState.externallyModified = false;
this.entityStore?.syncFromScene();
await this.messageHub.publish('scene:reloaded', { path });
logger.info(`Scene reloaded: ${path}`);
} catch (error) {
logger.error('Failed to reload scene:', error);
throw error;
}
}
public async saveSceneAs(filePath?: string): Promise<void> {
let path: string | null | undefined = filePath;
if (!path) {
const defaultName = this.sceneState.sceneName || 'Untitled';
let scenesDir: string | undefined;
// 获取场景目录,限制保存位置 | Get scenes directory to restrict save location
if (this.projectService?.isProjectOpen()) {
scenesDir = this.projectService.getScenesPath() ?? undefined;
}
path = await this.fileAPI.saveSceneDialog(defaultName, scenesDir);
if (!path) {
return;
}
}
if (!path.endsWith('.ecs')) {
path += '.ecs';
}
try {
const scene = Core.scene as Scene | null;
if (!scene) {
throw new Error('No active scene');
}
const jsonData = scene.serialize({
format: 'json',
pretty: true,
includeMetadata: true
}) as string;
await this.fileAPI.saveProject(path, jsonData);
const fileName = path.split(/[/\\]/).pop() || 'Untitled';
const sceneName = fileName.replace('.ecs', '');
// 获取文件修改时间 | Get file modification time
let mtime: number | null = null;
if (this.fileAPI.getFileMtime) {
try {
mtime = await this.fileAPI.getFileMtime(path);
} catch (e) {
logger.warn('Failed to get file mtime after save:', e);
}
}
this.sceneState = {
currentScenePath: path,
sceneName,
isModified: false,
isSaved: true,
lastKnownMtime: mtime,
externallyModified: false
};
await this.messageHub.publish('scene:saved', { path });
logger.info(`Scene saved as: ${path}`);
} catch (error) {
logger.error('Failed to save scene as:', error);
throw error;
}
}
public async exportScene(filePath?: string): Promise<void> {
let path: string | null | undefined = filePath;
if (!path) {
const defaultName = (this.sceneState.sceneName || 'Untitled') + '.ecs.bin';
let scenesDir: string | undefined;
// 获取场景目录,限制保存位置 | Get scenes directory to restrict save location
if (this.projectService?.isProjectOpen()) {
scenesDir = this.projectService.getScenesPath() ?? undefined;
}
path = await this.fileAPI.saveSceneDialog(defaultName, scenesDir);
if (!path) {
return;
}
}
if (!path.endsWith('.ecs.bin')) {
path += '.ecs.bin';
}
try {
const scene = Core.scene as Scene | null;
if (!scene) {
throw new Error('No active scene');
}
const binaryData = scene.serialize({
format: 'binary'
}) as Uint8Array;
await this.fileAPI.exportBinary(binaryData, path);
await this.messageHub.publish('scene:exported', { path });
logger.info(`Scene exported: ${path}`);
} catch (error) {
logger.error('Failed to export scene:', error);
throw error;
}
}
public getSceneState(): SceneState {
return { ...this.sceneState };
}
public markAsModified(): void {
if (!this.sceneState.isModified) {
this.sceneState.isModified = true;
this.messageHub.publishSync('scene:modified', {});
logger.debug('Scene marked as modified');
}
}
public async canClose(): Promise<boolean> {
if (!this.sceneState.isModified) {
return true;
}
return true;
}
// ===== 预制体编辑模式 API | Prefab Edit Mode API =====
/**
* 进入预制体编辑模式
* Enter prefab edit mode
*
* @param prefabPath - 预制体文件路径 | Prefab file path
*/
public async enterPrefabEditMode(prefabPath: string): Promise<void> {
// 如果已在预制体编辑模式,先退出
// If already in prefab edit mode, exit first
if (this.prefabEditModeState?.isActive) {
await this.exitPrefabEditMode(false);
}
const scene = Core.scene as Scene | null;
if (!scene) {
throw new Error('No active scene');
}
try {
// 1. 读取预制体文件 | Read prefab file
const prefabJson = await this.fileAPI.readFileContent(prefabPath);
const prefabData = PrefabSerializer.deserialize(prefabJson);
// 2. 验证预制体数据 | Validate prefab data
const validation = PrefabSerializer.validate(prefabData);
if (!validation.valid) {
throw new Error(`Invalid prefab: ${validation.errors?.join(', ')}`);
}
// 3. 保存当前场景状态 | Save current scene state
const savedScenePath = this.sceneState.currentScenePath;
const savedSceneName = this.sceneState.sceneName;
const savedSceneModified = this.sceneState.isModified;
// 4. 请求保存场景快照(通过 MessageHub由 EngineService 处理)
// Request to save scene snapshot (via MessageHub, handled by EngineService)
const snapshotSaved = await this.messageHub.request<void, boolean>(
'engine:saveSceneSnapshot',
undefined,
5000
).catch(() => false);
if (!snapshotSaved) {
logger.warn('Failed to save scene snapshot, proceeding without snapshot');
}
// 5. 清空场景 | Clear scene
scene.entities.removeAllEntities();
// 5.1 清理查询系统和系统缓存 | Clear query system and system caches
scene.querySystem.setEntities([]);
scene.clearSystemEntityCaches();
// 5.2 重置所有系统的实体跟踪状态 | Reset entity tracking for all systems
for (const system of scene.systems) {
system.resetEntityTracking();
}
// 6. 获取组件注册表 | Get component registry
// GlobalComponentRegistry.getAllComponentNames() 返回 Map<string, Function>
// 需要转换为 Map<string, ComponentType>
const nameToType = GlobalComponentRegistry.getAllComponentNames();
const componentRegistry = new Map<string, ComponentType>();
nameToType.forEach((type: Function, name: string) => {
componentRegistry.set(name, type as ComponentType);
});
// 7. 实例化预制体到场景 | Instantiate prefab to scene
logger.info(`Instantiating prefab with ${componentRegistry.size} registered component types`);
logger.debug('Available component types:', Array.from(componentRegistry.keys()));
this.prefabRootEntity = PrefabSerializer.instantiate(
prefabData,
scene,
componentRegistry,
{
trackInstance: false, // 编辑模式不追踪实例 | Don't track instance in edit mode
preserveIds: false
}
);
logger.info(`Prefab instantiated, root entity: ${this.prefabRootEntity?.name} (id: ${this.prefabRootEntity?.id})`);
logger.info(`Scene entity count: ${scene.entities.count}`);
// 7.1 强制重建查询系统 | Force rebuild query system
// 使用 setEntities 完全重置,确保所有索引正确重建
// Using setEntities to fully reset, ensuring all indexes are correctly rebuilt
const allEntities = Array.from(scene.entities.buffer);
scene.querySystem.setEntities(allEntities);
// 7.2 重置所有系统的实体跟踪状态,强制它们重新扫描
// Reset all system entity tracking, forcing them to rescan
for (const system of scene.systems) {
system.resetEntityTracking();
}
// 7.3 清理系统缓存 | Clear system caches
scene.clearSystemEntityCaches();
// 8. 加载场景资源(纹理、音频等)| Load scene resources (textures, audio, etc.)
if (this.sceneResourceManager) {
await this.sceneResourceManager.loadSceneResources(scene);
logger.info('Scene resources loaded for prefab');
} else {
logger.warn('SceneResourceManager not available, skipping resource loading');
}
// 9. 设置预制体编辑模式状态 | Set prefab edit mode state
const prefabName = prefabData.metadata.name || prefabPath.split(/[/\\]/).pop()?.replace('.prefab', '') || 'Prefab';
this.prefabEditModeState = {
isActive: true,
prefabPath,
prefabName,
prefabGuid: prefabData.metadata.guid,
originalPrefabData: prefabData,
originalScenePath: savedScenePath,
originalSceneName: savedSceneName,
originalSceneModified: savedSceneModified
};
// 10. 更新场景状态 | Update scene state
this.sceneState = {
currentScenePath: null,
sceneName: `Prefab: ${prefabName}`,
isModified: false,
isSaved: true,
lastKnownMtime: null,
externallyModified: false
};
// 11. 同步到 EntityStore | Sync to EntityStore
this.entityStore?.syncFromScene();
// 12. 发布事件 | Publish events
await this.messageHub.publish('prefab:editMode:enter', {
prefabPath,
prefabName,
prefabGuid: prefabData.metadata.guid
});
await this.messageHub.publish('prefab:editMode:changed', {
isActive: true,
prefabPath,
prefabName
});
logger.info(`Entered prefab edit mode: ${prefabPath}`);
} catch (error) {
logger.error('Failed to enter prefab edit mode:', error);
throw error;
}
}
/**
* 退出预制体编辑模式
* Exit prefab edit mode
*
* @param save - 是否保存修改 | Whether to save changes
*/
public async exitPrefabEditMode(save: boolean = false): Promise<void> {
if (!this.prefabEditModeState?.isActive) {
logger.warn('Not in prefab edit mode');
return;
}
const scene = Core.scene as Scene | null;
if (!scene) {
throw new Error('No active scene');
}
try {
// 1. 如果需要保存,先保存预制体 | If save requested, save prefab first
if (save && this.sceneState.isModified) {
await this.savePrefab();
}
// 2. 清空当前场景 | Clear current scene
scene.entities.removeAllEntities();
this.prefabRootEntity = null;
// 3. 请求恢复场景快照(通过 MessageHub由 EngineService 处理)
// Request to restore scene snapshot (via MessageHub, handled by EngineService)
const snapshotRestored = await this.messageHub.request<void, boolean>(
'engine:restoreSceneSnapshot',
undefined,
5000
).catch(() => false);
// 4. 恢复场景状态 | Restore scene state
const originalState = this.prefabEditModeState;
this.sceneState = {
currentScenePath: originalState.originalScenePath,
sceneName: originalState.originalSceneName,
isModified: originalState.originalSceneModified,
isSaved: !originalState.originalSceneModified,
lastKnownMtime: null,
externallyModified: false
};
// 5. 清除预制体编辑模式状态 | Clear prefab edit mode state
this.prefabEditModeState = null;
// 6. 同步到 EntityStore | Sync to EntityStore
this.entityStore?.syncFromScene();
// 7. 发布事件 | Publish events
await this.messageHub.publish('prefab:editMode:exit', { saved: save });
await this.messageHub.publish('prefab:editMode:changed', {
isActive: false
});
if (snapshotRestored) {
await this.messageHub.publish('scene:restored', {});
}
logger.info(`Exited prefab edit mode, saved: ${save}`);
} catch (error) {
logger.error('Failed to exit prefab edit mode:', error);
throw error;
}
}
/**
* 保存预制体
* Save prefab
*/
public async savePrefab(): Promise<void> {
if (!this.prefabEditModeState?.isActive) {
throw new Error('Not in prefab edit mode');
}
const scene = Core.scene as Scene | null;
if (!scene) {
throw new Error('No active scene');
}
if (!this.prefabRootEntity) {
throw new Error('No prefab root entity');
}
try {
const hierarchySystem = scene.getSystem(HierarchySystem) ?? undefined;
// 1. 从根实体创建预制体数据 | Create prefab data from root entity
const newPrefabData = PrefabSerializer.createPrefab(
this.prefabRootEntity,
{
name: this.prefabEditModeState.prefabName,
description: this.prefabEditModeState.originalPrefabData.metadata.description,
tags: this.prefabEditModeState.originalPrefabData.metadata.tags,
includeChildren: true
},
hierarchySystem
);
// 2. 保持原有 GUID | Preserve original GUID
if (this.prefabEditModeState.prefabGuid) {
newPrefabData.metadata.guid = this.prefabEditModeState.prefabGuid;
}
// 3. 保持原有创建时间,更新修改时间 | Preserve creation time, update modification time
newPrefabData.metadata.createdAt = this.prefabEditModeState.originalPrefabData.metadata.createdAt;
newPrefabData.metadata.modifiedAt = Date.now();
// 4. 序列化并保存 | Serialize and save
const prefabJson = PrefabSerializer.serialize(newPrefabData, true);
await this.fileAPI.saveProject(this.prefabEditModeState.prefabPath, prefabJson);
// 5. 更新原始数据(用于后续修改检测)| Update original data (for subsequent modification detection)
this.prefabEditModeState.originalPrefabData = newPrefabData;
// 6. 标记为已保存 | Mark as saved
this.sceneState.isModified = false;
this.sceneState.isSaved = true;
// 7. 发布事件 | Publish event
await this.messageHub.publish('prefab:saved', {
prefabPath: this.prefabEditModeState.prefabPath,
prefabName: this.prefabEditModeState.prefabName
});
logger.info(`Prefab saved: ${this.prefabEditModeState.prefabPath}`);
} catch (error) {
logger.error('Failed to save prefab:', error);
throw error;
}
}
/**
* 检查是否处于预制体编辑模式
* Check if in prefab edit mode
*/
public isPrefabEditMode(): boolean {
return this.prefabEditModeState?.isActive ?? false;
}
/**
* 获取预制体编辑模式状态
* Get prefab edit mode state
*/
public getPrefabEditModeState(): PrefabEditModeState | null {
return this.prefabEditModeState ? { ...this.prefabEditModeState } : null;
}
/**
* 检查预制体是否已修改
* Check if prefab has been modified
*/
public isPrefabModified(): boolean {
return (this.prefabEditModeState?.isActive ?? false) && this.sceneState.isModified;
}
private setupAutoModificationTracking(): void {
// 实体级别事件 | Entity-level events
const unsubscribeEntityAdded = this.messageHub.subscribe('entity:added', () => {
this.markAsModified();
});
const unsubscribeEntityRemoved = this.messageHub.subscribe('entity:removed', () => {
this.markAsModified();
});
const unsubscribeEntityReordered = this.messageHub.subscribe('entity:reordered', () => {
this.markAsModified();
});
// 组件级别事件 | Component-level events
const unsubscribeComponentAdded = this.messageHub.subscribe('component:added', () => {
this.markAsModified();
});
const unsubscribeComponentRemoved = this.messageHub.subscribe('component:removed', () => {
this.markAsModified();
});
const unsubscribeComponentPropertyChanged = this.messageHub.subscribe('component:property:changed', () => {
this.markAsModified();
});
// 通用场景修改事件 | Generic scene modification event
const unsubscribeSceneModified = this.messageHub.subscribe('scene:modified', () => {
this.markAsModified();
});
this.unsubscribeHandlers.push(
unsubscribeEntityAdded,
unsubscribeEntityRemoved,
unsubscribeEntityReordered,
unsubscribeComponentAdded,
unsubscribeComponentRemoved,
unsubscribeComponentPropertyChanged,
unsubscribeSceneModified
);
logger.debug('Auto modification tracking setup complete');
}
public dispose(): void {
for (const unsubscribe of this.unsubscribeHandlers) {
unsubscribe();
}
this.unsubscribeHandlers = [];
logger.info('SceneManagerService disposed');
}
}

View File

@@ -0,0 +1,128 @@
import type { Scene, Entity } from '@esengine/ecs-framework';
/**
* 默认实体创建函数类型
* Default entity creator function type
*/
export type DefaultEntityCreator = (scene: Scene) => Entity | null;
/**
* 场景模板配置
* Scene template configuration
*/
export interface SceneTemplate {
/** 模板名称 / Template name */
name: string;
/** 模板描述 / Template description */
description?: string;
/** 默认实体创建器列表 / Default entity creators */
defaultEntities: DefaultEntityCreator[];
}
/**
* 场景模板注册表
* Registry for scene templates that define default entities
*
* This allows the editor-app to register what entities should be created
* when a new scene is created, without editor-core needing to know about
* specific components like CameraComponent.
*
* 这允许 editor-app 注册新建场景时应该创建的实体,
* 而 editor-core 不需要知道具体的组件如 CameraComponent。
*/
export class SceneTemplateRegistry {
private static templates: Map<string, SceneTemplate> = new Map();
private static defaultTemplateName = 'default';
/**
* 注册场景模板
* Register a scene template
*/
static registerTemplate(template: SceneTemplate): void {
this.templates.set(template.name, template);
}
/**
* 注册默认实体创建器到默认模板
* Register a default entity creator to the default template
*/
static registerDefaultEntity(creator: DefaultEntityCreator): void {
let defaultTemplate = this.templates.get(this.defaultTemplateName);
if (!defaultTemplate) {
defaultTemplate = {
name: this.defaultTemplateName,
description: 'Default scene template',
defaultEntities: []
};
this.templates.set(this.defaultTemplateName, defaultTemplate);
}
defaultTemplate.defaultEntities.push(creator);
}
/**
* 获取场景模板
* Get a scene template by name
*/
static getTemplate(name: string): SceneTemplate | undefined {
return this.templates.get(name);
}
/**
* 获取默认模板
* Get the default template
*/
static getDefaultTemplate(): SceneTemplate | undefined {
return this.templates.get(this.defaultTemplateName);
}
/**
* 设置默认模板名称
* Set the default template name
*/
static setDefaultTemplateName(name: string): void {
this.defaultTemplateName = name;
}
/**
* 获取所有模板名称
* Get all template names
*/
static getTemplateNames(): string[] {
return Array.from(this.templates.keys());
}
/**
* 为场景创建默认实体
* Create default entities for a scene using a template
*
* @param scene - 目标场景 / Target scene
* @param templateName - 模板名称,默认使用默认模板 / Template name, uses default if not specified
* @returns 创建的实体列表 / List of created entities
*/
static createDefaultEntities(scene: Scene, templateName?: string): Entity[] {
const template = templateName
? this.templates.get(templateName)
: this.getDefaultTemplate();
if (!template) {
return [];
}
const entities: Entity[] = [];
for (const creator of template.defaultEntities) {
const entity = creator(scene);
if (entity) {
entities.push(entity);
}
}
return entities;
}
/**
* 清除所有模板
* Clear all templates
*/
static clear(): void {
this.templates.clear();
}
}

View File

@@ -0,0 +1,184 @@
import type { IService } from '@esengine/ecs-framework';
import { Injectable, createLogger } from '@esengine/ecs-framework';
import type { ISerializer } from '../Plugin/EditorModule';
import { createRegistryToken } from './BaseRegistry';
const logger = createLogger('SerializerRegistry');
/**
* 序列化器注册表
*
* 管理所有数据序列化器的注册和查询。
*/
@Injectable()
export class SerializerRegistry implements IService {
private readonly _serializers = new Map<string, ISerializer>();
/**
* 注册序列化器
*
* @param pluginName - 插件名称
* @param serializer - 序列化器实例
*/
public register(pluginName: string, serializer: ISerializer): void {
const type = serializer.getSupportedType();
const key = `${pluginName}:${type}`;
if (this._serializers.has(key)) {
logger.warn(`Serializer for ${key} is already registered`);
return;
}
this._serializers.set(key, serializer);
logger.info(`Registered serializer: ${key}`);
}
/**
* 批量注册序列化器
*
* @param pluginName - 插件名称
* @param serializers - 序列化器实例数组
*/
public registerMultiple(pluginName: string, serializers: ISerializer[]): void {
for (const serializer of serializers) {
this.register(pluginName, serializer);
}
}
/**
* 注销序列化器
*
* @param pluginName - 插件名称
* @param type - 数据类型
* @returns 是否成功注销
*/
public unregister(pluginName: string, type: string): boolean {
const key = `${pluginName}:${type}`;
const result = this._serializers.delete(key);
if (result) {
logger.info(`Unregistered serializer: ${key}`);
}
return result;
}
/**
* 注销插件的所有序列化器
*
* @param pluginName - 插件名称
*/
public unregisterAll(pluginName: string): void {
const prefix = `${pluginName}:`;
const keysToDelete: string[] = [];
for (const key of this._serializers.keys()) {
if (key.startsWith(prefix)) {
keysToDelete.push(key);
}
}
for (const key of keysToDelete) {
this._serializers.delete(key);
logger.info(`Unregistered serializer: ${key}`);
}
}
/**
* 获取序列化器
*
* @param pluginName - 插件名称
* @param type - 数据类型
* @returns 序列化器实例,如果未找到则返回 undefined
*/
public get(pluginName: string, type: string): ISerializer | undefined {
const key = `${pluginName}:${type}`;
return this._serializers.get(key);
}
/**
* 查找支持指定类型的序列化器
*
* @param type - 数据类型
* @returns 序列化器实例数组
*/
public findByType(type: string): ISerializer[] {
const result: ISerializer[] = [];
for (const [key, serializer] of this._serializers) {
if (key.endsWith(`:${type}`)) {
result.push(serializer);
}
}
return result;
}
/**
* 获取所有序列化器
*
* @returns 序列化器映射表
*/
public getAll(): Map<string, ISerializer> {
return new Map(this._serializers);
}
/**
* 检查序列化器是否已注册
*
* @param pluginName - 插件名称
* @param type - 数据类型
* @returns 是否已注册
*/
public has(pluginName: string, type: string): boolean {
const key = `${pluginName}:${type}`;
return this._serializers.has(key);
}
/**
* 序列化数据
*
* @param pluginName - 插件名称
* @param type - 数据类型
* @param data - 要序列化的数据
* @returns 二进制数据
* @throws 如果序列化器未注册
*/
public serialize<T = any>(pluginName: string, type: string, data: T): Uint8Array {
const serializer = this.get(pluginName, type);
if (!serializer) {
throw new Error(`Serializer not found: ${pluginName}:${type}`);
}
return serializer.serialize(data);
}
/**
* 反序列化数据
*
* @param pluginName - 插件名称
* @param type - 数据类型
* @param data - 二进制数据
* @returns 反序列化后的数据
* @throws 如果序列化器未注册
*/
public deserialize<T = any>(pluginName: string, type: string, data: Uint8Array): T {
const serializer = this.get(pluginName, type);
if (!serializer) {
throw new Error(`Serializer not found: ${pluginName}:${type}`);
}
return serializer.deserialize(data);
}
/**
* 释放资源
*/
public dispose(): void {
this._serializers.clear();
logger.info('SerializerRegistry disposed');
}
}
/** @zh 序列化器注册表服务标识符 @en Serializer registry service identifier */
export const ISerializerRegistry = createRegistryToken<SerializerRegistry>('SerializerRegistry');

View File

@@ -0,0 +1,262 @@
import { createLogger, type ILogger, type IService } from '@esengine/ecs-framework';
import { createRegistryToken } from './BaseRegistry';
/**
* @zh 设置类型
* @en Setting type
*/
export type SettingType = 'string' | 'number' | 'boolean' | 'select' | 'color' | 'range' | 'pluginList' | 'collisionMatrix' | 'moduleList';
/**
* Localizable text - can be a plain string or a translation key (prefixed with '$')
* 可本地化文本 - 可以是普通字符串或翻译键(以 '$' 为前缀)
*
* @example
* // Plain text (not recommended for user-facing strings)
* title: 'Appearance'
*
* // Translation key (recommended)
* title: '$pluginSettings.appearance.title'
*/
export type LocalizableText = string;
/**
* Check if text is a translation key (starts with '$')
* 检查文本是否为翻译键(以 '$' 开头)
*/
export function isTranslationKey(text: string): boolean {
return text.startsWith('$');
}
/**
* Get the actual translation key (without '$' prefix)
* 获取实际的翻译键(去掉 '$' 前缀)
*/
export function getTranslationKey(text: string): string {
return text.startsWith('$') ? text.slice(1) : text;
}
export interface SettingOption {
label: LocalizableText;
value: any;
}
export interface SettingValidator {
validate: (value: any) => boolean;
errorMessage: LocalizableText;
}
export interface SettingDescriptor {
key: string;
/** Label text or translation key (prefixed with '$') | 标签文本或翻译键(以 '$' 为前缀) */
label: LocalizableText;
type: SettingType;
defaultValue: any;
/** Description text or translation key (prefixed with '$') | 描述文本或翻译键(以 '$' 为前缀) */
description?: LocalizableText;
/** Placeholder text or translation key (prefixed with '$') | 占位符文本或翻译键(以 '$' 为前缀) */
placeholder?: LocalizableText;
options?: SettingOption[];
validator?: SettingValidator;
min?: number;
max?: number;
step?: number;
/**
* Custom renderer component (for complex types like collisionMatrix)
* 自定义渲染器组件(用于 collisionMatrix 等复杂类型)
*/
customRenderer?: React.ComponentType<any>;
}
export interface SettingSection {
id: string;
/** Title text or translation key (prefixed with '$') | 标题文本或翻译键(以 '$' 为前缀) */
title: LocalizableText;
/** Description text or translation key (prefixed with '$') | 描述文本或翻译键(以 '$' 为前缀) */
description?: LocalizableText;
icon?: string;
settings: SettingDescriptor[];
}
export interface SettingCategory {
id: string;
/** Title text or translation key (prefixed with '$') | 标题文本或翻译键(以 '$' 为前缀) */
title: LocalizableText;
/** Description text or translation key (prefixed with '$') | 描述文本或翻译键(以 '$' 为前缀) */
description?: LocalizableText;
sections: SettingSection[];
}
/**
* @zh 设置注册表
* @en Settings Registry
*/
export class SettingsRegistry implements IService {
private readonly _categories = new Map<string, SettingCategory>();
private readonly _logger: ILogger;
constructor() {
this._logger = createLogger('SettingsRegistry');
}
/** @zh 释放资源 @en Dispose resources */
dispose(): void {
this._categories.clear();
this._logger.debug('Disposed');
}
/**
* @zh 注册设置分类
* @en Register setting category
*/
registerCategory(category: SettingCategory): void {
if (this._categories.has(category.id)) {
this._logger.warn(`Overwriting category: ${category.id}`);
}
this._categories.set(category.id, category);
this._logger.debug(`Registered category: ${category.id}`);
}
/**
* @zh 注册设置分区
* @en Register setting section
*/
registerSection(categoryId: string, section: SettingSection): void {
const category = this._ensureCategory(categoryId);
const existingIndex = category.sections.findIndex(s => s.id === section.id);
if (existingIndex >= 0) {
category.sections[existingIndex] = section;
this._logger.warn(`Overwriting section: ${section.id} in ${categoryId}`);
} else {
category.sections.push(section);
this._logger.debug(`Registered section: ${section.id} in ${categoryId}`);
}
}
/**
* @zh 注册单个设置项
* @en Register single setting
*/
registerSetting(categoryId: string, sectionId: string, setting: SettingDescriptor): void {
const category = this._ensureCategory(categoryId);
const section = this._ensureSection(category, sectionId);
const existingIndex = section.settings.findIndex(s => s.key === setting.key);
if (existingIndex >= 0) {
section.settings[existingIndex] = setting;
this._logger.warn(`Overwriting setting: ${setting.key} in ${sectionId}`);
} else {
section.settings.push(setting);
this._logger.debug(`Registered setting: ${setting.key} in ${sectionId}`);
}
}
/**
* @zh 确保分类存在
* @en Ensure category exists
*/
private _ensureCategory(categoryId: string): SettingCategory {
let category = this._categories.get(categoryId);
if (!category) {
category = { id: categoryId, title: categoryId, sections: [] };
this._categories.set(categoryId, category);
}
return category;
}
/**
* @zh 确保分区存在
* @en Ensure section exists
*/
private _ensureSection(category: SettingCategory, sectionId: string): SettingSection {
let section = category.sections.find(s => s.id === sectionId);
if (!section) {
section = { id: sectionId, title: sectionId, settings: [] };
category.sections.push(section);
}
return section;
}
/** @zh 注销分类 @en Unregister category */
unregisterCategory(categoryId: string): void {
this._categories.delete(categoryId);
}
/** @zh 注销分区 @en Unregister section */
unregisterSection(categoryId: string, sectionId: string): void {
const category = this._categories.get(categoryId);
if (!category) return;
category.sections = category.sections.filter(s => s.id !== sectionId);
if (category.sections.length === 0) {
this._categories.delete(categoryId);
}
}
/** @zh 获取分类 @en Get category */
getCategory(categoryId: string): SettingCategory | undefined {
return this._categories.get(categoryId);
}
/** @zh 获取所有分类 @en Get all categories */
getAllCategories(): SettingCategory[] {
return Array.from(this._categories.values());
}
/** @zh 获取设置项 @en Get setting */
getSetting(categoryId: string, sectionId: string, key: string): SettingDescriptor | undefined {
const section = this._categories.get(categoryId)?.sections.find(s => s.id === sectionId);
return section?.settings.find(s => s.key === key);
}
/** @zh 获取所有设置项 @en Get all settings */
getAllSettings(): Map<string, SettingDescriptor> {
const allSettings = new Map<string, SettingDescriptor>();
for (const category of this._categories.values()) {
for (const section of category.sections) {
for (const setting of section.settings) {
allSettings.set(setting.key, setting);
}
}
}
return allSettings;
}
/**
* @zh 验证设置值
* @en Validate setting value
*/
validateSetting(setting: SettingDescriptor, value: unknown): boolean {
if (setting.validator) {
return setting.validator.validate(value);
}
switch (setting.type) {
case 'number':
case 'range':
if (typeof value !== 'number') return false;
if (setting.min !== undefined && value < setting.min) return false;
if (setting.max !== undefined && value > setting.max) return false;
return true;
case 'boolean':
return typeof value === 'boolean';
case 'string':
return typeof value === 'string';
case 'select':
return setting.options?.some(opt => opt.value === value) ?? false;
case 'color':
return typeof value === 'string' && /^#[0-9A-Fa-f]{6}$/.test(value);
default:
return true;
}
}
}
/** @zh 设置注册表服务标识符 @en Settings registry service identifier */
export const ISettingsRegistry = createRegistryToken<SettingsRegistry>('SettingsRegistry');

View File

@@ -0,0 +1,226 @@
import type { IService } from '@esengine/ecs-framework';
import { Injectable, createLogger } from '@esengine/ecs-framework';
import type { MenuItem, ToolbarItem, PanelDescriptor } from '../Types/UITypes';
import { createRegistryToken } from './BaseRegistry';
const logger = createLogger('UIRegistry');
/**
* @zh UI 注册表 - 管理所有编辑器 UI 扩展点的注册和查询
* @en UI Registry - Manages all editor UI extension point registration and queries
*/
/** @zh 带排序权重的项 @en Item with sort order */
interface IOrdered {
readonly order?: number;
}
@Injectable()
export class UIRegistry implements IService {
private readonly _menus = new Map<string, MenuItem>();
private readonly _toolbarItems = new Map<string, ToolbarItem>();
private readonly _panels = new Map<string, PanelDescriptor>();
// ========== 辅助方法 | Helper Methods ==========
/** @zh 按 order 排序 @en Sort by order */
private _sortByOrder<T extends IOrdered>(items: T[]): T[] {
return items.sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
}
// ========== 菜单管理 | Menu Management ==========
/**
* @zh 注册菜单项
* @en Register menu item
*/
public registerMenu(item: MenuItem): void {
if (this._menus.has(item.id)) {
logger.warn(`Menu item ${item.id} is already registered`);
return;
}
this._menus.set(item.id, item);
logger.debug(`Registered menu item: ${item.id}`);
}
/**
* @zh 批量注册菜单项
* @en Register multiple menu items
*/
public registerMenus(items: MenuItem[]): void {
for (const item of items) {
this.registerMenu(item);
}
}
/**
* @zh 注销菜单项
* @en Unregister menu item
*/
public unregisterMenu(id: string): boolean {
const result = this._menus.delete(id);
if (result) {
logger.debug(`Unregistered menu item: ${id}`);
}
return result;
}
/** @zh 获取菜单项 @en Get menu item */
public getMenu(id: string): MenuItem | undefined {
return this._menus.get(id);
}
/**
* @zh 获取所有菜单项
* @en Get all menu items
*/
public getAllMenus(): MenuItem[] {
return this._sortByOrder(Array.from(this._menus.values()));
}
/**
* @zh 获取指定父菜单的子菜单
* @en Get child menus of specified parent
*/
public getChildMenus(parentId: string): MenuItem[] {
return this._sortByOrder(
Array.from(this._menus.values()).filter((item) => item.parentId === parentId)
);
}
// ========== 工具栏管理 | Toolbar Management ==========
/**
* @zh 注册工具栏项
* @en Register toolbar item
*/
public registerToolbarItem(item: ToolbarItem): void {
if (this._toolbarItems.has(item.id)) {
logger.warn(`Toolbar item ${item.id} is already registered`);
return;
}
this._toolbarItems.set(item.id, item);
logger.debug(`Registered toolbar item: ${item.id}`);
}
/**
* @zh 批量注册工具栏项
* @en Register multiple toolbar items
*/
public registerToolbarItems(items: ToolbarItem[]): void {
for (const item of items) {
this.registerToolbarItem(item);
}
}
/**
* @zh 注销工具栏项
* @en Unregister toolbar item
*/
public unregisterToolbarItem(id: string): boolean {
const result = this._toolbarItems.delete(id);
if (result) {
logger.debug(`Unregistered toolbar item: ${id}`);
}
return result;
}
/** @zh 获取工具栏项 @en Get toolbar item */
public getToolbarItem(id: string): ToolbarItem | undefined {
return this._toolbarItems.get(id);
}
/**
* @zh 获取所有工具栏项
* @en Get all toolbar items
*/
public getAllToolbarItems(): ToolbarItem[] {
return this._sortByOrder(Array.from(this._toolbarItems.values()));
}
/**
* @zh 获取指定组的工具栏项
* @en Get toolbar items by group
*/
public getToolbarItemsByGroup(groupId: string): ToolbarItem[] {
return this._sortByOrder(
Array.from(this._toolbarItems.values()).filter((item) => item.groupId === groupId)
);
}
// ========== 面板管理 | Panel Management ==========
/**
* @zh 注册面板
* @en Register panel
*/
public registerPanel(panel: PanelDescriptor): void {
if (this._panels.has(panel.id)) {
logger.warn(`Panel ${panel.id} is already registered`);
return;
}
this._panels.set(panel.id, panel);
logger.debug(`Registered panel: ${panel.id}`);
}
/**
* @zh 批量注册面板
* @en Register multiple panels
*/
public registerPanels(panels: PanelDescriptor[]): void {
for (const panel of panels) {
this.registerPanel(panel);
}
}
/**
* @zh 注销面板
* @en Unregister panel
*/
public unregisterPanel(id: string): boolean {
const result = this._panels.delete(id);
if (result) {
logger.debug(`Unregistered panel: ${id}`);
}
return result;
}
/** @zh 获取面板 @en Get panel */
public getPanel(id: string): PanelDescriptor | undefined {
return this._panels.get(id);
}
/**
* @zh 获取所有面板
* @en Get all panels
*/
public getAllPanels(): PanelDescriptor[] {
return this._sortByOrder(Array.from(this._panels.values()));
}
/**
* @zh 获取指定位置的面板
* @en Get panels by position
*/
public getPanelsByPosition(position: string): PanelDescriptor[] {
return this._sortByOrder(
Array.from(this._panels.values()).filter((panel) => panel.position === position)
);
}
// ========== 生命周期 | Lifecycle ==========
/** @zh 释放资源 @en Dispose resources */
public dispose(): void {
this._menus.clear();
this._toolbarItems.clear();
this._panels.clear();
logger.debug('Disposed');
}
}
/** @zh UI 注册表服务标识符 @en UI registry service identifier */
export const IUIRegistry = createRegistryToken<UIRegistry>('UIRegistry');

View File

@@ -0,0 +1,328 @@
/**
* Hot Reload Coordinator
* 热更新协调器
*
* Coordinates the hot reload process to ensure safe code updates
* without causing race conditions or inconsistent state.
*
* 协调热更新过程,确保代码更新安全,不会导致竞态条件或状态不一致。
*
* @example
* ```typescript
* const coordinator = new HotReloadCoordinator();
* await coordinator.performHotReload(async () => {
* // Recompile and reload user code
* await userCodeService.compile(options);
* await userCodeService.load(outputPath, target);
* });
* ```
*/
import { createLogger } from '@esengine/ecs-framework';
import type { HotReloadEvent } from './IUserCodeService';
const logger = createLogger('HotReloadCoordinator');
/**
* Hot reload phase enumeration
* 热更新阶段枚举
*/
export const enum EHotReloadPhase {
/** Idle state, no hot reload in progress | 空闲状态,没有热更新进行中 */
Idle = 'idle',
/** Preparing for hot reload, pausing systems | 准备热更新,暂停系统 */
Preparing = 'preparing',
/** Compiling user code | 编译用户代码 */
Compiling = 'compiling',
/** Loading new modules | 加载新模块 */
Loading = 'loading',
/** Updating component instances | 更新组件实例 */
UpdatingInstances = 'updating-instances',
/** Updating systems | 更新系统 */
UpdatingSystems = 'updating-systems',
/** Resuming systems | 恢复系统 */
Resuming = 'resuming',
/** Hot reload complete | 热更新完成 */
Complete = 'complete',
/** Hot reload failed | 热更新失败 */
Failed = 'failed'
}
/**
* Hot reload status interface
* 热更新状态接口
*/
export interface IHotReloadStatus {
/** Current phase | 当前阶段 */
phase: EHotReloadPhase;
/** Error message if failed | 失败时的错误信息 */
error?: string;
/** Timestamp when current phase started | 当前阶段开始时间戳 */
startTime: number;
/** Number of updated instances | 更新的实例数量 */
updatedInstances?: number;
/** Number of updated systems | 更新的系统数量 */
updatedSystems?: number;
}
/**
* Hot reload options
* 热更新选项
*/
export interface IHotReloadOptions {
/**
* Timeout for hot reload process in milliseconds.
* 热更新过程的超时时间(毫秒)。
*
* @default 30000
*/
timeout?: number;
/**
* Whether to restore previous state on failure.
* 失败时是否恢复到之前的状态。
*
* @default true
*/
restoreOnFailure?: boolean;
/**
* Callback for phase changes.
* 阶段变化回调。
*/
onPhaseChange?: (phase: EHotReloadPhase) => void;
}
/**
* Hot Reload Coordinator
* 热更新协调器
*
* Manages the hot reload process lifecycle:
* 1. Pause ECS update loop
* 2. Execute hot reload tasks (compile, load, update)
* 3. Resume ECS update loop
*
* 管理热更新过程生命周期:
* 1. 暂停 ECS 更新循环
* 2. 执行热更新任务(编译、加载、更新)
* 3. 恢复 ECS 更新循环
*/
export class HotReloadCoordinator {
private _status: IHotReloadStatus = {
phase: EHotReloadPhase.Idle,
startTime: 0
};
private _coreReference: any = null;
private _previousPausedState: boolean = false;
private _hotReloadPromise: Promise<void> | null = null;
private _onPhaseChange?: (phase: EHotReloadPhase) => void;
/**
* Get current hot reload status.
* 获取当前热更新状态。
*/
public get status(): Readonly<IHotReloadStatus> {
return { ...this._status };
}
/**
* Check if hot reload is in progress.
* 检查热更新是否进行中。
*/
public get isInProgress(): boolean {
return this._status.phase !== EHotReloadPhase.Idle &&
this._status.phase !== EHotReloadPhase.Complete &&
this._status.phase !== EHotReloadPhase.Failed;
}
/**
* Initialize coordinator with Core reference.
* 使用 Core 引用初始化协调器。
*
* @param coreModule - ECS Framework Core module | ECS 框架 Core 模块
*/
public initialize(coreModule: any): void {
this._coreReference = coreModule;
logger.info('HotReloadCoordinator initialized');
}
/**
* Perform a coordinated hot reload.
* 执行协调的热更新。
*
* This method ensures the ECS loop is paused during hot reload
* and properly resumed afterward, even if an error occurs.
*
* 此方法确保 ECS 循环在热更新期间暂停,并在之后正确恢复,即使发生错误。
*
* @param reloadTask - Async function that performs the actual reload | 执行实际重载的异步函数
* @param options - Hot reload options | 热更新选项
* @returns Promise that resolves when hot reload is complete | 热更新完成时解析的 Promise
*/
public async performHotReload(
reloadTask: () => Promise<HotReloadEvent | void>,
options: IHotReloadOptions = {}
): Promise<HotReloadEvent | void> {
// Prevent concurrent hot reloads | 防止并发热更新
if (this._hotReloadPromise) {
logger.warn('Hot reload already in progress, waiting for completion | 热更新已在进行中,等待完成');
await this._hotReloadPromise;
}
const {
timeout = 30000,
restoreOnFailure = true,
onPhaseChange
} = options;
this._onPhaseChange = onPhaseChange;
this._status = {
phase: EHotReloadPhase.Idle,
startTime: Date.now()
};
let result: HotReloadEvent | void = undefined;
this._hotReloadPromise = (async () => {
try {
// Phase 1: Prepare - Pause ECS | 阶段 1准备 - 暂停 ECS
this._setPhase(EHotReloadPhase.Preparing);
this._pauseECS();
// Create timeout promise | 创建超时 Promise
const timeoutPromise = new Promise<never>((_, reject) => {
setTimeout(() => {
reject(new Error(`Hot reload timed out after ${timeout}ms | 热更新超时 ${timeout}ms`));
}, timeout);
});
// Phase 2-5: Execute reload task with timeout | 阶段 2-5带超时执行重载任务
this._setPhase(EHotReloadPhase.Compiling);
result = await Promise.race([
reloadTask(),
timeoutPromise
]);
// Phase 6: Resume ECS | 阶段 6恢复 ECS
this._setPhase(EHotReloadPhase.Resuming);
this._resumeECS();
// Phase 7: Complete | 阶段 7完成
this._setPhase(EHotReloadPhase.Complete);
logger.info('Hot reload completed successfully | 热更新成功完成', {
duration: Date.now() - this._status.startTime
});
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
this._status.error = errorMessage;
this._setPhase(EHotReloadPhase.Failed);
logger.error('Hot reload failed | 热更新失败:', error);
// Always resume ECS on failure | 失败时始终恢复 ECS
if (restoreOnFailure) {
this._resumeECS();
}
throw error;
} finally {
this._hotReloadPromise = null;
}
})();
await this._hotReloadPromise;
return result;
}
/**
* Update hot reload status with instance count.
* 更新热更新状态的实例数量。
*
* @param instanceCount - Number of updated instances | 更新的实例数量
*/
public reportInstanceUpdate(instanceCount: number): void {
this._status.updatedInstances = instanceCount;
this._setPhase(EHotReloadPhase.UpdatingInstances);
}
/**
* Update hot reload status with system count.
* 更新热更新状态的系统数量。
*
* @param systemCount - Number of updated systems | 更新的系统数量
*/
public reportSystemUpdate(systemCount: number): void {
this._status.updatedSystems = systemCount;
this._setPhase(EHotReloadPhase.UpdatingSystems);
}
/**
* Pause ECS update loop.
* 暂停 ECS 更新循环。
*/
private _pauseECS(): void {
if (!this._coreReference) {
logger.warn('Core reference not set, cannot pause ECS | Core 引用未设置,无法暂停 ECS');
return;
}
// Store previous paused state to restore later | 存储之前的暂停状态以便后续恢复
this._previousPausedState = this._coreReference.paused ?? false;
// Pause ECS | 暂停 ECS
this._coreReference.paused = true;
logger.debug('ECS paused for hot reload | ECS 已暂停以进行热更新');
}
/**
* Resume ECS update loop.
* 恢复 ECS 更新循环。
*/
private _resumeECS(): void {
if (!this._coreReference) {
logger.warn('Core reference not set, cannot resume ECS | Core 引用未设置,无法恢复 ECS');
return;
}
// Restore previous paused state | 恢复之前的暂停状态
this._coreReference.paused = this._previousPausedState;
logger.debug('ECS resumed after hot reload | 热更新后 ECS 已恢复', {
paused: this._coreReference.paused
});
}
/**
* Set current phase and notify listener.
* 设置当前阶段并通知监听器。
*/
private _setPhase(phase: EHotReloadPhase): void {
this._status.phase = phase;
if (this._onPhaseChange) {
try {
this._onPhaseChange(phase);
} catch (error) {
logger.warn('Error in phase change callback | 阶段变化回调错误:', error);
}
}
logger.debug(`Hot reload phase: ${phase} | 热更新阶段: ${phase}`);
}
/**
* Reset coordinator state.
* 重置协调器状态。
*/
public reset(): void {
this._status = {
phase: EHotReloadPhase.Idle,
startTime: 0
};
this._hotReloadPromise = null;
this._onPhaseChange = undefined;
}
}

View File

@@ -0,0 +1,469 @@
/**
* 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)
*/
import type { IHotReloadOptions } from './HotReloadCoordinator';
/**
* 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;
}
/**
* SDK module info for shim generation.
* 用于生成 shim 的 SDK 模块信息。
*/
export interface SDKModuleInfo {
/** Module ID (e.g., "particle", "engine-core") | 模块 ID */
id: string;
/** Full package name (e.g., "@esengine/particle") | 完整包名 */
name: string;
/** Whether module has runtime code | 模块是否有运行时代码 */
hasRuntime?: boolean;
/** Global key for window.__ESENGINE__ (optional, defaults to camelCase of id) | 全局键名 */
globalKey?: string;
}
/**
* User code compilation options.
* 用户代码编译选项。
*/
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 (default: 'esm') | 输出格式(默认:'esm'*/
format?: 'esm' | 'iife';
/**
* SDK modules information (reserved for future use).
* SDK 模块信息(保留供将来使用)。
*
* Currently SDK is handled via external dependencies and global variable.
* 当前 SDK 通过外部依赖和全局变量处理。
*/
sdkModules?: SDKModuleInfo[];
}
/**
* 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;
}
/**
* Hot reloadable component/system interface.
* 可热更新的组件/系统接口。
*
* Implement this interface in user components or systems to preserve state
* during hot reload. Without this interface, hot reload only updates the
* prototype chain; with it, you can save and restore custom state.
*
* 在用户组件或系统中实现此接口以在热更新时保留状态。
* 如果不实现此接口,热更新只会更新原型链;实现后,可以保存和恢复自定义状态。
*
* @example
* ```typescript
* @ECSComponent('MyComponent')
* class MyComponent extends Component implements IHotReloadable {
* private _cachedData: Map<string, any> = new Map();
*
* onBeforeHotReload(): Record<string, unknown> {
* // Save state that needs to survive hot reload
* return {
* cachedData: Array.from(this._cachedData.entries())
* };
* }
*
* onAfterHotReload(state: Record<string, unknown>): void {
* // Restore state after hot reload
* const entries = state.cachedData as [string, any][];
* this._cachedData = new Map(entries);
* }
* }
* ```
*/
export interface IHotReloadable {
/**
* Called before hot reload to save state.
* 在热更新前调用以保存状态。
*
* Return an object containing any state that needs to survive the hot reload.
* The returned object will be passed to onAfterHotReload after the prototype is updated.
*
* 返回包含需要保留的状态的对象。
* 返回的对象将在原型更新后传递给 onAfterHotReload。
*
* @returns State object to preserve | 需要保留的状态对象
*/
onBeforeHotReload?(): Record<string, unknown>;
/**
* Called after hot reload to restore state.
* 在热更新后调用以恢复状态。
*
* @param state - State saved by onBeforeHotReload | onBeforeHotReload 保存的状态
*/
onAfterHotReload?(state: Record<string, unknown>): void;
}
/**
* User Code Service interface.
* 用户代码服务接口。
*
* 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 | 用户代码模块
* @param componentRegistry - Optional ComponentRegistry to register components | 可选的 ComponentRegistry 用于注册组件
*/
registerComponents(module: UserCodeModule, componentRegistry?: any): void;
/**
* Register user systems to scene.
* 注册用户系统到场景。
*
* Automatically detects and instantiates System subclasses from user module,
* then adds them to the scene.
* 自动检测用户模块中的 System 子类并实例化,然后添加到场景。
*
* @param module - User code module | 用户代码模块
* @param scene - Scene to add systems | 要添加系统的场景
* @returns Array of registered system instances | 注册的系统实例数组
*/
registerSystems(module: UserCodeModule, scene: any): any[];
/**
* Unregister user systems from scene.
* 从场景注销用户系统。
*
* Removes previously registered user systems from the scene.
* 从场景移除之前注册的用户系统。
*
* @param scene - Scene to remove systems | 要移除系统的场景
*/
unregisterSystems(scene: any): void;
/**
* Get registered user systems.
* 获取已注册的用户系统。
*
* @returns Array of registered system instances | 注册的系统实例数组
*/
getRegisteredSystems(): any[];
/**
* Register editor extensions from user module.
* 从用户模块注册编辑器扩展。
*
* Automatically detects and registers:
* 自动检测并注册:
* - Component inspectors
* - Gizmo providers
*
* @param module - User code module | 用户代码模块
* @param inspectorRegistry - Component inspector registry | 组件检查器注册表
*/
registerEditorExtensions(module: UserCodeModule, inspectorRegistry?: any): void;
/**
* Unregister editor extensions.
* 注销编辑器扩展。
*
* @param inspectorRegistry - Component inspector registry | 组件检查器注册表
*/
unregisterEditorExtensions(inspectorRegistry?: any): void;
/**
* Start watching for file changes (hot reload).
* 开始监视文件变更(热更新)。
*
* @param projectPath - Project root path | 项目根路径
* @param onReload - Callback when code is reloaded | 代码重新加载时的回调
* @param options - Hot reload options | 热更新选项
*/
watch(
projectPath: string,
onReload: (event: HotReloadEvent) => void,
options?: IHotReloadOptions
): Promise<void>;
/**
* Stop watching for file changes.
* 停止监视文件变更。
*/
stopWatch(): Promise<void>;
/**
* Check if watching is active.
* 检查是否正在监视。
*/
isWatching(): boolean;
/**
* Wait for user code to be ready (compiled and loaded).
* 等待用户代码准备就绪(已编译并加载)。
*
* This method is used to synchronize scene loading with user code compilation.
* Call this before loading a scene to ensure user components are registered.
* 此方法用于同步场景加载与用户代码编译。
* 在加载场景之前调用此方法以确保用户组件已注册。
*
* @returns Promise that resolves when user code is ready | 当用户代码就绪时解决的 Promise
*/
waitForReady(): Promise<void>;
/**
* Signal that user code is ready.
* 发出用户代码就绪信号。
*
* Called after user code compilation and registration is complete.
* 在用户代码编译和注册完成后调用。
*/
signalReady(): void;
/**
* Reset the ready state (for project switching).
* 重置就绪状态(用于项目切换)。
*
* Called when opening a new project to reset the ready promise.
* 打开新项目时调用以重置就绪 Promise。
*/
resetReady(): void;
/**
* Get the user code realm.
* 获取用户代码隔离域。
*
* The realm provides isolated registration for user components, systems, and services.
* 隔离域提供用户组件、系统和服务的隔离注册。
*
* @returns User code realm instance | 用户代码隔离域实例
*/
getUserCodeRealm(): import('@esengine/runtime-core').UserCodeRealm;
/**
* Reset the user code realm for project switching.
* 重置用户代码隔离域(用于项目切换)。
*
* This clears all user-registered components, systems, and services,
* preparing for a new project's user code.
* 这会清除所有用户注册的组件、系统和服务,为新项目的用户代码做准备。
*/
resetRealm(): void;
}
import { EditorConfig } from '../../Config';
/**
* Default scripts directory name.
* 默认脚本目录名称。
*
* @deprecated Use EditorConfig.paths.scripts instead
*/
export const SCRIPTS_DIR = EditorConfig.paths.scripts;
/**
* Editor scripts subdirectory name.
* 编辑器脚本子目录名称。
*
* @deprecated Use EditorConfig.paths.editorScripts instead
*/
export const EDITOR_SCRIPTS_DIR = EditorConfig.paths.editorScripts;
/**
* Default output directory for compiled user code.
* 编译后用户代码的默认输出目录。
*
* @deprecated Use EditorConfig.paths.compiled instead
*/
export const USER_CODE_OUTPUT_DIR = EditorConfig.paths.compiled;
// Re-export hot reload coordinator types
// 重新导出热更新协调器类型
export {
EHotReloadPhase,
type IHotReloadStatus,
type IHotReloadOptions
} from './HotReloadCoordinator';

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,131 @@
/**
* 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 ESM using esbuild
* (@esengine/sdk marked as external)
* ↓
* [UserCodeService.load()] - Loads via project:// protocol + import()
* ↓
* [registerComponents()] - Registers with ECS runtime
* [registerEditorExtensions()] - Registers inspectors/gizmos
* ↓
* [UserCodeService.watch()] - Hot reload on file changes
* ```
*
* # Architecture | 架构
*
* - **Compilation**: ESM format with `external: ['@esengine/sdk']`
* - **Loading**: Reads file via Tauri, loads via Blob URL + import()
* - **Runtime**: SDK accessed via `window.__ESENGINE_SDK__` global
* - **Hot Reload**: File watching via Rust backend + Tauri events
*
* Note: Browser's import() only supports http/https/blob protocols.
* Custom protocols like project:// are not supported for ESM imports.
*
* # Example User Component | 用户组件示例
*
* ```typescript
* // 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,
IHotReloadStatus,
IHotReloadOptions
} from './IUserCodeService';
export {
UserCodeTarget,
SCRIPTS_DIR,
EDITOR_SCRIPTS_DIR,
USER_CODE_OUTPUT_DIR,
EHotReloadPhase
} from './IUserCodeService';
export { UserCodeService } from './UserCodeService';
export { HotReloadCoordinator } from './HotReloadCoordinator';

View File

@@ -0,0 +1,325 @@
/**
* VirtualNodeRegistry
*
* Registry for virtual child nodes in the scene hierarchy.
* Allows components to expose internal structure as read-only nodes
* in the hierarchy panel.
*
* Uses event-driven architecture for efficient change notification.
*
* 场景层级中虚拟子节点的注册表。
* 允许组件将内部结构作为只读节点暴露在层级面板中。
* 使用事件驱动架构实现高效的变化通知。
*/
import { createLogger, type Component, type ComponentType, type Entity } from '@esengine/ecs-framework';
const logger = createLogger('VirtualNodeRegistry');
/**
* Virtual node data
* 虚拟节点数据
*/
export interface IVirtualNode {
/** Unique ID within the parent component | 父组件内的唯一 ID */
id: string;
/** Display name | 显示名称 */
name: string;
/** Node type for icon selection | 节点类型(用于图标选择) */
type: string;
/** Child nodes | 子节点 */
children: IVirtualNode[];
/** Whether this node is visible | 此节点是否可见 */
visible: boolean;
/** Node-specific data for Inspector | Inspector 使用的节点数据 */
data: Record<string, unknown>;
/** World X position (for Gizmo) | 世界 X 坐标(用于 Gizmo */
x: number;
/** World Y position (for Gizmo) | 世界 Y 坐标(用于 Gizmo */
y: number;
/** Width (for Gizmo) | 宽度(用于 Gizmo */
width: number;
/** Height (for Gizmo) | 高度(用于 Gizmo */
height: number;
}
/**
* Virtual node provider function
* 虚拟节点提供者函数
*
* Returns an array of virtual nodes for a component instance.
* 为组件实例返回虚拟节点数组。
*/
export type VirtualNodeProviderFn<T extends Component = Component> = (
component: T,
entity: Entity
) => IVirtualNode[];
/**
* Change event types for virtual nodes
* 虚拟节点的变化事件类型
*/
export type VirtualNodeChangeType = 'loaded' | 'updated' | 'disposed';
/**
* Virtual node change event payload
* 虚拟节点变化事件载荷
*/
export interface VirtualNodeChangeEvent {
/** Entity ID that changed | 发生变化的实体 ID */
entityId: number;
/** Type of change | 变化类型 */
type: VirtualNodeChangeType;
/** Component that triggered the change (optional) | 触发变化的组件(可选) */
component?: Component;
}
/**
* Change listener function type
* 变化监听器函数类型
*/
export type VirtualNodeChangeListener = (event: VirtualNodeChangeEvent) => void;
/**
* VirtualNodeRegistry
*
* Manages virtual node providers for different component types.
* Provides event-driven change notifications for efficient UI updates.
*
* 管理不同组件类型的虚拟节点提供者。
* 提供事件驱动的变化通知以实现高效的 UI 更新。
*/
export class VirtualNodeRegistry {
private static providers = new Map<ComponentType, VirtualNodeProviderFn>();
/** Currently selected virtual node info | 当前选中的虚拟节点信息 */
private static selectedVirtualNodeInfo: {
entityId: number;
virtualNodeId: string;
} | null = null;
/** Change listeners | 变化监听器 */
private static changeListeners = new Set<VirtualNodeChangeListener>();
// ============= Provider Registration | 提供者注册 =============
/**
* Register a virtual node provider for a component type
* 为组件类型注册虚拟节点提供者
*/
static register<T extends Component>(
componentType: ComponentType<T>,
provider: VirtualNodeProviderFn<T>
): void {
this.providers.set(componentType, provider as VirtualNodeProviderFn);
}
/**
* Unregister a virtual node provider
* 取消注册虚拟节点提供者
*/
static unregister(componentType: ComponentType): void {
this.providers.delete(componentType);
}
/**
* Check if a component type has a virtual node provider
* 检查组件类型是否有虚拟节点提供者
*/
static hasProvider(componentType: ComponentType): boolean {
return this.providers.has(componentType);
}
// ============= Virtual Node Collection | 虚拟节点收集 =============
/**
* Get virtual nodes for a component
* 获取组件的虚拟节点
*/
static getVirtualNodes(
component: Component,
entity: Entity
): IVirtualNode[] {
const componentType = component.constructor as ComponentType;
const provider = this.providers.get(componentType);
if (provider) {
try {
return provider(component, entity);
} catch (e) {
logger.warn(`Error in provider for ${componentType.name}:`, e);
return [];
}
}
return [];
}
/**
* Get all virtual nodes for an entity
* 获取实体的所有虚拟节点
*/
static getAllVirtualNodesForEntity(entity: Entity): IVirtualNode[] {
const allNodes: IVirtualNode[] = [];
for (const component of entity.components) {
const nodes = this.getVirtualNodes(component, entity);
allNodes.push(...nodes);
}
return allNodes;
}
/**
* Check if an entity has any components with virtual node providers
* 检查实体是否有任何带有虚拟节点提供者的组件
*/
static hasAnyVirtualNodeProvider(entity: Entity): boolean {
for (const component of entity.components) {
const componentType = component.constructor as ComponentType;
if (this.providers.has(componentType)) {
return true;
}
}
return false;
}
// ============= Event System | 事件系统 =============
/**
* Subscribe to virtual node changes
* 订阅虚拟节点变化
*
* @param listener Callback function for change events
* @returns Unsubscribe function
*
* @example
* ```typescript
* const unsubscribe = VirtualNodeRegistry.onChange((event) => {
* if (event.entityId === selectedEntityId) {
* refreshVirtualNodes();
* }
* });
*
* // Later, cleanup
* unsubscribe();
* ```
*/
static onChange(listener: VirtualNodeChangeListener): () => void {
this.changeListeners.add(listener);
return () => {
this.changeListeners.delete(listener);
};
}
/**
* Notify that an entity's virtual nodes have changed
* Components should call this when their internal structure changes
*
* 通知实体的虚拟节点已更改
* 组件在内部结构变化时应调用此方法
*
* @param entityId The entity ID that changed
* @param type Type of change ('loaded', 'updated', 'disposed')
* @param component Optional component reference
*
* @example
* ```typescript
* // In FGUIComponent after loading completes:
* VirtualNodeRegistry.notifyChange(this.entity.id, 'loaded', this);
*
* // In FGUIComponent when switching component:
* VirtualNodeRegistry.notifyChange(this.entity.id, 'updated', this);
* ```
*/
static notifyChange(
entityId: number,
type: VirtualNodeChangeType = 'updated',
component?: Component
): void {
const event: VirtualNodeChangeEvent = { entityId, type, component };
for (const listener of this.changeListeners) {
try {
listener(event);
} catch (e) {
logger.warn('Error in change listener:', e);
}
}
}
/**
* Create a React hook-friendly subscription
* 创建对 React Hook 友好的订阅
*
* @param entityIds Set of entity IDs to watch
* @param callback Callback when any watched entity changes
* @returns Unsubscribe function
*/
static watchEntities(
entityIds: Set<number>,
callback: () => void
): () => void {
return this.onChange((event) => {
if (entityIds.has(event.entityId)) {
callback();
}
});
}
// ============= Selection State | 选择状态 =============
/**
* Set the currently selected virtual node
* 设置当前选中的虚拟节点
*/
static setSelectedVirtualNode(entityId: number, virtualNodeId: string): void {
this.selectedVirtualNodeInfo = { entityId, virtualNodeId };
}
/**
* Clear the virtual node selection
* 清除虚拟节点选择
*/
static clearSelectedVirtualNode(): void {
this.selectedVirtualNodeInfo = null;
}
/**
* Get the currently selected virtual node info
* 获取当前选中的虚拟节点信息
*/
static getSelectedVirtualNode(): { entityId: number; virtualNodeId: string } | null {
return this.selectedVirtualNodeInfo;
}
/**
* Check if a specific virtual node is selected
* 检查特定虚拟节点是否被选中
*/
static isVirtualNodeSelected(entityId: number, virtualNodeId: string): boolean {
return this.selectedVirtualNodeInfo !== null &&
this.selectedVirtualNodeInfo.entityId === entityId &&
this.selectedVirtualNodeInfo.virtualNodeId === virtualNodeId;
}
// ============= Cleanup | 清理 =============
/**
* Clear all registered providers and listeners
* 清除所有已注册的提供者和监听器
*/
static clear(): void {
this.providers.clear();
this.changeListeners.clear();
this.selectedVirtualNodeInfo = null;
}
}

View File

@@ -0,0 +1,162 @@
import { createLogger, type ILogger, type IService } from '@esengine/ecs-framework';
import type { ComponentType } from 'react';
import { createRegistryToken } from './BaseRegistry';
/**
* @zh 窗口描述符
* @en Window descriptor
*/
export interface WindowDescriptor {
/** @zh 窗口唯一标识 @en Unique window ID */
id: string;
/** @zh 窗口组件 @en Window component */
component: ComponentType<unknown>;
/** @zh 窗口标题 @en Window title */
title?: string;
/** @zh 默认宽度 @en Default width */
defaultWidth?: number;
/** @zh 默认高度 @en Default height */
defaultHeight?: number;
}
/**
* @zh 窗口实例
* @en Window instance
*/
export interface WindowInstance {
/** @zh 窗口描述符 @en Window descriptor */
descriptor: WindowDescriptor;
/** @zh 是否打开 @en Whether the window is open */
isOpen: boolean;
/** @zh 窗口参数 @en Window parameters */
params?: Record<string, unknown>;
}
/**
* @zh 窗口注册表服务 - 管理插件注册的窗口组件
* @en Window Registry Service - Manages plugin-registered window components
*/
export class WindowRegistry implements IService {
private readonly _windows = new Map<string, WindowDescriptor>();
private readonly _openWindows = new Map<string, WindowInstance>();
private readonly _listeners = new Set<() => void>();
private readonly _logger: ILogger;
constructor() {
this._logger = createLogger('WindowRegistry');
}
/**
* @zh 注册窗口
* @en Register a window
*/
registerWindow(descriptor: WindowDescriptor): void {
if (this._windows.has(descriptor.id)) {
this._logger.warn(`Window already registered: ${descriptor.id}`);
return;
}
this._windows.set(descriptor.id, descriptor);
this._logger.debug(`Registered window: ${descriptor.id}`);
}
/**
* @zh 取消注册窗口
* @en Unregister a window
*/
unregisterWindow(windowId: string): void {
this._windows.delete(windowId);
this._openWindows.delete(windowId);
this._notifyListeners();
this._logger.debug(`Unregistered window: ${windowId}`);
}
/** @zh 获取窗口描述符 @en Get window descriptor */
getWindow(windowId: string): WindowDescriptor | undefined {
return this._windows.get(windowId);
}
/** @zh 获取所有窗口描述符 @en Get all window descriptors */
getAllWindows(): WindowDescriptor[] {
return Array.from(this._windows.values());
}
/**
* @zh 打开窗口
* @en Open a window
*/
openWindow(windowId: string, params?: Record<string, unknown>): void {
const descriptor = this._windows.get(windowId);
if (!descriptor) {
this._logger.warn(`Window not registered: ${windowId}`);
return;
}
this._openWindows.set(windowId, {
descriptor,
isOpen: true,
params
});
this._notifyListeners();
this._logger.debug(`Opened window: ${windowId}`);
}
/**
* @zh 关闭窗口
* @en Close a window
*/
closeWindow(windowId: string): void {
this._openWindows.delete(windowId);
this._notifyListeners();
this._logger.debug(`Closed window: ${windowId}`);
}
/** @zh 获取打开的窗口实例 @en Get open window instance */
getOpenWindow(windowId: string): WindowInstance | undefined {
return this._openWindows.get(windowId);
}
/** @zh 获取所有打开的窗口 @en Get all open windows */
getAllOpenWindows(): WindowInstance[] {
return Array.from(this._openWindows.values());
}
/** @zh 检查窗口是否打开 @en Check if window is open */
isWindowOpen(windowId: string): boolean {
return this._openWindows.has(windowId);
}
/**
* @zh 添加变化监听器
* @en Add change listener
* @returns @zh 取消订阅函数 @en Unsubscribe function
*/
addListener(listener: () => void): () => void {
this._listeners.add(listener);
return () => {
this._listeners.delete(listener);
};
}
/** @zh 通知所有监听器 @en Notify all listeners */
private _notifyListeners(): void {
for (const listener of this._listeners) {
listener();
}
}
/** @zh 清空所有窗口 @en Clear all windows */
clear(): void {
this._windows.clear();
this._openWindows.clear();
this._listeners.clear();
this._logger.debug('Cleared');
}
/** @zh 释放资源 @en Dispose resources */
dispose(): void {
this.clear();
}
}
/** @zh 窗口注册表服务标识符 @en Window registry service identifier */
export const IWindowRegistry = createRegistryToken<WindowRegistry>('WindowRegistry');

View File

@@ -0,0 +1,73 @@
/**
* 文件 API 接口
*
* 定义编辑器与文件系统交互的抽象接口
* 具体实现由上层应用提供(如 TauriFileAPI
*/
export interface IFileAPI {
/**
* 打开场景文件选择对话框
* @returns 用户选择的文件路径,取消则返回 null
*/
openSceneDialog(): Promise<string | null>;
/**
* 打开保存场景对话框
* Open save scene dialog
*
* @param defaultName 默认文件名 | Default file name
* @param scenesDir 场景目录(限制保存位置)| Scenes directory (restrict save location)
* @returns 用户选择的文件路径,取消则返回 null | Selected path or null
*/
saveSceneDialog(defaultName?: string, scenesDir?: string): Promise<string | null>;
/**
* 读取文件内容
* @param path 文件路径
* @returns 文件内容(文本格式)
*/
readFileContent(path: string): Promise<string>;
/**
* 保存项目文件
* @param path 保存路径
* @param data 文件内容(文本格式)
*/
saveProject(path: string, data: string): Promise<void>;
/**
* 导出二进制文件
* @param data 二进制数据
* @param path 保存路径
*/
exportBinary(data: Uint8Array, path: string): Promise<void>;
/**
* 创建目录
* @param path 目录路径
*/
createDirectory(path: string): Promise<void>;
/**
* 写入文件内容
* @param path 文件路径
* @param content 文件内容
*/
writeFileContent(path: string, content: string): Promise<void>;
/**
* 检查路径是否存在
* @param path 文件或目录路径
* @returns 路径是否存在
*/
pathExists(path: string): Promise<boolean>;
/**
* 获取文件修改时间
* Get file modification time
*
* @param path 文件路径 | File path
* @returns 文件修改时间(毫秒时间戳)| File modification time (milliseconds timestamp)
*/
getFileMtime?(path: string): Promise<number>;
}

View File

@@ -0,0 +1,106 @@
/**
* 菜单项配置
*/
export interface MenuItem {
/**
* 菜单项唯一标识
*/
id: string;
/**
* 显示文本
*/
label: string;
/**
* 父菜单ID用于构建层级菜单
*/
parentId?: string;
/**
* 点击回调
*/
onClick?: () => void;
/**
* 键盘快捷键
*/
shortcut?: string;
/**
* 图标
*/
icon?: string;
/**
* 是否禁用
*/
disabled?: boolean;
/**
* 分隔符
*/
separator?: boolean;
/**
* 排序权重
*/
order?: number;
}
/**
* 工具栏项配置
*/
export interface ToolbarItem {
/**
* 工具栏项唯一标识
*/
id: string;
/**
* 显示文本
*/
label: string;
/**
* 工具栏组ID
*/
groupId: string;
/**
* 点击回调
*/
onClick?: () => void;
/**
* 图标
*/
icon?: string;
/**
* 是否禁用
*/
disabled?: boolean;
/**
* 排序权重
*/
order?: number;
}
// Re-export PanelPosition and PanelDescriptor from Plugin system
export { PanelPosition, type PanelDescriptor } from '../Plugin/EditorModule';
/**
* UI 扩展点类型
*/
export enum UIExtensionType {
Menu = 'menu',
Toolbar = 'toolbar',
Panel = 'panel',
Inspector = 'inspector',
StatusBar = 'statusbar'
}
// Re-export EntityCreationTemplate from Plugin system
export type { EntityCreationTemplate } from '../Plugin/EditorModule';

View File

@@ -0,0 +1,49 @@
/**
* @zh 全局类型声明
* @en Global type declarations
*
* @zh 扩展 Window 接口以支持编辑器运行时全局变量
* @en Extend Window interface to support editor runtime global variables
*/
/**
* @zh SDK 全局对象结构
* @en SDK global object structure
*/
interface ESEngineSDK {
Core: typeof import('@esengine/ecs-framework').Core;
Scene: typeof import('@esengine/ecs-framework').Scene;
Entity: typeof import('@esengine/ecs-framework').Entity;
Component: typeof import('@esengine/ecs-framework').Component;
System: typeof import('@esengine/ecs-framework').System;
[key: string]: unknown;
}
/**
* @zh 插件容器结构
* @en Plugin container structure
*/
interface PluginContainer {
[pluginName: string]: unknown;
}
/**
* @zh 用户代码导出结构
* @en User code exports structure
*/
interface UserExports {
[name: string]: unknown;
}
declare global {
interface Window {
// ESEngine 全局变量(与 EditorConfig.globals 对应)
// ESEngine globals (matching EditorConfig.globals)
__ESENGINE_SDK__: ESEngineSDK | undefined;
__ESENGINE_PLUGINS__: PluginContainer | undefined;
__USER_RUNTIME_EXPORTS__: UserExports | undefined;
__USER_EDITOR_EXPORTS__: UserExports | undefined;
}
}
export {};

View File

@@ -0,0 +1,110 @@
/**
* @esengine/editor-core
*
* Plugin-based editor framework for ECS Framework
* 基于插件的 ECS 编辑器框架
*/
// ============================================================================
// Service Tokens | 服务令牌 (推荐导入)
// ============================================================================
export * from './tokens';
// ============================================================================
// Plugin System | 插件系统
// ============================================================================
export * from './Config';
export * from './Plugin';
// ============================================================================
// Registry Base | 注册表基类
// ============================================================================
export * from './Services/BaseRegistry';
// ============================================================================
// Core Services | 核心服务
// ============================================================================
export * from './Services/MessageHub';
export * from './Services/LocaleService';
export * from './Services/LogService';
export * from './Services/CommandManager';
export * from './Services/SettingsRegistry';
// ============================================================================
// Entity & Component Services | 实体与组件服务
// ============================================================================
export * from './Services/EntityStoreService';
export * from './Services/ComponentRegistry';
export * from './Services/ComponentDiscoveryService';
export * from './Services/SerializerRegistry';
export * from './Services/PropertyMetadata';
// ============================================================================
// Scene & Project Services | 场景与项目服务
// ============================================================================
export * from './Services/ProjectService';
export * from './Services/SceneManagerService';
export * from './Services/SceneTemplateRegistry';
export * from './Services/PrefabService';
// ============================================================================
// UI & Inspector Services | UI 与检视器服务
// ============================================================================
export * from './Services/UIRegistry';
export * from './Services/InspectorRegistry';
export * from './Services/PropertyRendererRegistry';
export * from './Services/FieldEditorRegistry';
export * from './Services/ComponentInspectorRegistry';
export * from './Services/WindowRegistry';
// ============================================================================
// Asset & File Services | 资产与文件服务
// ============================================================================
export * from './Services/AssetRegistryService';
export * from './Services/FileActionRegistry';
export * from './Services/VirtualNodeRegistry';
// ============================================================================
// Viewport & Gizmo Services | 视口与 Gizmo 服务
// ============================================================================
export * from './Services/IViewportService';
export * from './Services/PreviewSceneService';
export * from './Services/EditorViewportService';
export * from './Services/GizmoInteractionService';
export * from './Gizmos';
export * from './Rendering';
// ============================================================================
// Build & Compile System | 构建与编译系统
// ============================================================================
export * from './Services/Build';
export * from './Services/UserCode';
export * from './Services/CompilerRegistry';
// ============================================================================
// Module System | 模块系统
// ============================================================================
export * from './Services/Module';
export * from './Module/IEventBus';
export * from './Module/ICommandRegistry';
export * from './Module/IPanelRegistry';
export * from './Module/IModuleContext';
export * from './Module/IEditorModule';
// ============================================================================
// Interfaces | 接口定义
// ============================================================================
export * from './Services/ICompiler';
export * from './Services/ICommand';
export * from './Services/BaseCommand';
export * from './Services/IEditorDataStore';
export * from './Services/IFileSystem';
export * from './Services/IDialog';
export * from './Services/INotification';
export * from './Services/IInspectorProvider';
export * from './Services/IPropertyRenderer';
export * from './Services/IFieldEditor';
export * from './Services/ComponentActionRegistry';
export * from './Services/EntityCreationRegistry';
export * from './Types/IFileAPI';
export * from './Types/UITypes';

View File

@@ -0,0 +1,239 @@
/**
* Editor Core 服务令牌
* Editor Core service tokens
*
* 定义 editor-core 模块导出的服务令牌和接口。
* Defines service tokens and interfaces exported by editor-core module.
*
* 遵循 "谁定义接口,谁导出 Token" 的规范。
* Follows the "who defines interface, who exports token" principle.
*
* @example
* ```typescript
* // 消费方导入 Token | Consumer imports Token
* import { LocaleServiceToken, MessageHubToken, EntityStoreServiceToken } from '@esengine/editor-core';
*
* // 获取服务 | Get service
* const localeService = context.services.get(LocaleServiceToken);
* const messageHub = context.services.get(MessageHubToken);
* ```
*/
import { createServiceToken } from '@esengine/ecs-framework';
import type { LocaleService, Locale, TranslationParams, PluginTranslations } from './Services/LocaleService';
import type { MessageHub, MessageHandler, RequestHandler } from './Services/MessageHub';
import type { EntityStoreService, EntityTreeNode } from './Services/EntityStoreService';
import type { PrefabService, PrefabPropertyOverride, IPrefabFileAPI } from './Services/PrefabService';
import type { IGizmoInteractionService } from './Services/GizmoInteractionService';
// ============================================================================
// LocaleService Token
// 国际化服务令牌
// ============================================================================
/**
* LocaleService 接口
* LocaleService interface
*
* 提供类型安全的服务访问接口。
* Provides type-safe service access interface.
*/
export interface ILocaleService {
/** 获取当前语言 | Get current locale */
getLocale(): Locale;
/** 设置当前语言 | Set current locale */
setLocale(locale: Locale): void;
/** 翻译文本 | Translate text */
t(key: string, params?: TranslationParams, fallback?: string): string;
/** 扩展翻译 | Extend translations */
extendTranslations(namespace: string, translations: PluginTranslations): void;
/** 监听语言变化 | Listen to locale changes */
onLocaleChange(listener: (locale: Locale) => void): () => void;
}
/**
* 国际化服务令牌
* Localization service token
*
* 用于注册和获取国际化服务。
* For registering and getting localization service.
*/
export const LocaleServiceToken = createServiceToken<ILocaleService>('localeService');
// ============================================================================
// MessageHub Token
// 消息总线令牌
// ============================================================================
/**
* MessageHub 服务接口
* MessageHub service interface
*
* 提供类型安全的消息通信接口。
* Provides type-safe message communication interface.
*/
export interface IMessageHubService {
/** 订阅消息 | Subscribe to message */
subscribe<T = unknown>(topic: string, handler: MessageHandler<T>): () => void;
/** 订阅一次性消息 | Subscribe to one-time message */
subscribeOnce<T = unknown>(topic: string, handler: MessageHandler<T>): () => void;
/** 发布消息 | Publish message */
publish<T = unknown>(topic: string, data?: T): Promise<void>;
/** 注册请求处理器 | Register request handler */
registerRequest<TRequest = unknown, TResponse = unknown>(
topic: string,
handler: RequestHandler<TRequest, TResponse>
): () => void;
/** 发送请求 | Send request */
request<TRequest = unknown, TResponse = unknown>(
topic: string,
data?: TRequest,
timeout?: number
): Promise<TResponse>;
}
/**
* 消息总线服务令牌
* Message hub service token
*
* 用于注册和获取消息总线服务。
* For registering and getting message hub service.
*/
export const MessageHubToken = createServiceToken<IMessageHubService>('messageHub');
// ============================================================================
// EntityStoreService Token
// 实体存储服务令牌
// ============================================================================
/**
* EntityStoreService 接口
* EntityStoreService interface
*
* 提供类型安全的实体存储服务访问接口。
* Provides type-safe entity store service access interface.
*/
export interface IEntityStoreService {
/** 添加实体 | Add entity */
addEntity(entity: unknown, parent?: unknown): void;
/** 移除实体 | Remove entity */
removeEntity(entity: unknown): void;
/** 选择实体 | Select entity */
selectEntity(entity: unknown | null): void;
/** 获取选中的实体 | Get selected entity */
getSelectedEntity(): unknown | null;
/** 获取所有实体 | Get all entities */
getAllEntities(): unknown[];
/** 获取根实体 | Get root entities */
getRootEntities(): unknown[];
/** 根据ID获取实体 | Get entity by ID */
getEntity(id: number): unknown | undefined;
/** 清空实体 | Clear all entities */
clear(): void;
/** 构建实体树 | Build entity tree */
buildEntityTree(): EntityTreeNode[];
}
/**
* 实体存储服务令牌
* Entity store service token
*
* 用于注册和获取实体存储服务。
* For registering and getting entity store service.
*/
export const EntityStoreServiceToken = createServiceToken<IEntityStoreService>('entityStoreService');
// ============================================================================
// Re-export types for convenience
// 重新导出类型方便使用
// ============================================================================
export type { Locale, TranslationParams, PluginTranslations } from './Services/LocaleService';
export type { MessageHandler, RequestHandler } from './Services/MessageHub';
export type { EntityTreeNode } from './Services/EntityStoreService';
// ============================================================================
// EditorPrefabService Token
// 编辑器预制体服务令牌
// ============================================================================
/**
* EditorPrefabService 接口
* EditorPrefabService interface
*
* 编辑器侧的预制体实例管理服务。
* Editor-side prefab instance management service.
*
* 注意:这与 asset-system 的 IPrefabService 不同!
* - asset-system.IPrefabService: 运行时预制体资源加载/实例化
* - editor-core.IEditorPrefabService: 编辑器预制体实例状态管理
*
* Note: This is different from asset-system's IPrefabService!
* - asset-system.IPrefabService: Runtime prefab asset loading/instantiation
* - editor-core.IEditorPrefabService: Editor prefab instance state management
*/
export interface IEditorPrefabService {
/** 设置文件 API | Set file API */
setFileAPI(fileAPI: IPrefabFileAPI): void;
/** 检查是否为预制体实例 | Check if prefab instance */
isPrefabInstance(entity: unknown): boolean;
/** 检查是否为预制体实例根节点 | Check if prefab instance root */
isPrefabInstanceRoot(entity: unknown): boolean;
/** 获取预制体实例组件 | Get prefab instance component */
getPrefabInstanceComponent(entity: unknown): unknown | null;
/** 获取预制体实例根实体 | Get prefab instance root entity */
getPrefabInstanceRoot(entity: unknown): unknown | null;
/** 获取属性覆盖列表 | Get property overrides */
getOverrides(entity: unknown): PrefabPropertyOverride[];
/** 检查是否有修改 | Check if has modifications */
hasModifications(entity: unknown): boolean;
/** 获取修改数量 | Get modification count */
getModificationCount(entity: unknown): number;
/** 应用修改到源预制体 | Apply to source prefab */
applyToPrefab(entity: unknown): Promise<boolean>;
/** 还原实例到源预制体状态 | Revert instance to source prefab state */
revertInstance(entity: unknown): Promise<boolean>;
/** 还原单个属性 | Revert single property */
revertProperty(entity: unknown, componentType: string, propertyPath: string): Promise<boolean>;
/** 断开预制体链接 | Break prefab link */
breakPrefabLink(entity: unknown): void;
}
/**
* 编辑器预制体服务令牌
* Editor prefab service token
*
* 用于注册和获取编辑器预制体服务。
* For registering and getting editor prefab service.
*/
export const EditorPrefabServiceToken = createServiceToken<IEditorPrefabService>('editorPrefabService');
// Re-export types for convenience
// 重新导出类型方便使用
export type { PrefabPropertyOverride, IPrefabFileAPI } from './Services/PrefabService';
// ============================================================================
// GizmoInteractionService Token
// Gizmo 交互服务令牌
// ============================================================================
/**
* Gizmo 交互服务令牌
* Gizmo interaction service token
*
* 用于注册和获取 Gizmo 交互服务。
* For registering and getting gizmo interaction service.
*/
export const GizmoInteractionServiceToken = createServiceToken<IGizmoInteractionService>('gizmoInteractionService');
// Re-export interface for convenience
// 重新导出接口方便使用
export type { IGizmoInteractionService } from './Services/GizmoInteractionService';
// Re-export classes for direct use (backwards compatibility)
// 重新导出类以供直接使用(向后兼容)
export { LocaleService } from './Services/LocaleService';
export { MessageHub } from './Services/MessageHub';
export { EntityStoreService } from './Services/EntityStoreService';
export { PrefabService } from './Services/PrefabService';
export { GizmoInteractionService } from './Services/GizmoInteractionService';