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:
375
packages/editor/editor-core/src/Config/EditorConfig.ts
Normal file
375
packages/editor/editor-core/src/Config/EditorConfig.ts
Normal 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'];
|
||||
}
|
||||
5
packages/editor/editor-core/src/Config/index.ts
Normal file
5
packages/editor/editor-core/src/Config/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
/**
|
||||
* 配置模块导出
|
||||
* Configuration module exports
|
||||
*/
|
||||
export * from './EditorConfig';
|
||||
262
packages/editor/editor-core/src/Gizmos/GizmoHitTester.ts
Normal file
262
packages/editor/editor-core/src/Gizmos/GizmoHitTester.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
}
|
||||
201
packages/editor/editor-core/src/Gizmos/GizmoRegistry.ts
Normal file
201
packages/editor/editor-core/src/Gizmos/GizmoRegistry.ts
Normal 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);
|
||||
}
|
||||
218
packages/editor/editor-core/src/Gizmos/IGizmoProvider.ts
Normal file
218
packages/editor/editor-core/src/Gizmos/IGizmoProvider.ts
Normal 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) | 原点 X(0-1,默认 0.5 居中) */
|
||||
originX: number;
|
||||
/** Origin Y (0-1, default 0.5 for center) | 原点 Y(0-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,
|
||||
};
|
||||
13
packages/editor/editor-core/src/Gizmos/index.ts
Normal file
13
packages/editor/editor-core/src/Gizmos/index.ts
Normal 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';
|
||||
25
packages/editor/editor-core/src/Module/ICommandRegistry.ts
Normal file
25
packages/editor/editor-core/src/Module/ICommandRegistry.ts
Normal 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 }>;
|
||||
}
|
||||
12
packages/editor/editor-core/src/Module/IEditorModule.ts
Normal file
12
packages/editor/editor-core/src/Module/IEditorModule.ts
Normal 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>;
|
||||
}
|
||||
16
packages/editor/editor-core/src/Module/IEventBus.ts
Normal file
16
packages/editor/editor-core/src/Module/IEventBus.ts
Normal 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;
|
||||
}
|
||||
19
packages/editor/editor-core/src/Module/IModuleContext.ts
Normal file
19
packages/editor/editor-core/src/Module/IModuleContext.ts
Normal 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;
|
||||
}
|
||||
8
packages/editor/editor-core/src/Module/IPanelRegistry.ts
Normal file
8
packages/editor/editor-core/src/Module/IPanelRegistry.ts
Normal 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[];
|
||||
}
|
||||
340
packages/editor/editor-core/src/Plugin/EditorModule.ts
Normal file
340
packages/editor/editor-core/src/Plugin/EditorModule.ts
Normal 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>;
|
||||
33
packages/editor/editor-core/src/Plugin/PluginDescriptor.ts
Normal file
33
packages/editor/editor-core/src/Plugin/PluginDescriptor.ts
Normal 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
|
||||
1333
packages/editor/editor-core/src/Plugin/PluginManager.ts
Normal file
1333
packages/editor/editor-core/src/Plugin/PluginManager.ts
Normal file
File diff suppressed because it is too large
Load Diff
8
packages/editor/editor-core/src/Plugin/index.ts
Normal file
8
packages/editor/editor-core/src/Plugin/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* 插件系统
|
||||
* Plugin System
|
||||
*/
|
||||
|
||||
export * from './PluginDescriptor';
|
||||
export * from './EditorModule';
|
||||
export * from './PluginManager';
|
||||
198
packages/editor/editor-core/src/Rendering/IViewportOverlay.ts
Normal file
198
packages/editor/editor-core/src/Rendering/IViewportOverlay.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
6
packages/editor/editor-core/src/Rendering/index.ts
Normal file
6
packages/editor/editor-core/src/Rendering/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* Rendering module exports
|
||||
* 渲染模块导出
|
||||
*/
|
||||
|
||||
export * from './IViewportOverlay';
|
||||
989
packages/editor/editor-core/src/Services/AssetRegistryService.ts
Normal file
989
packages/editor/editor-core/src/Services/AssetRegistryService.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
25
packages/editor/editor-core/src/Services/BaseCommand.ts
Normal file
25
packages/editor/editor-core/src/Services/BaseCommand.ts
Normal 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} 不支持合并操作`);
|
||||
}
|
||||
}
|
||||
301
packages/editor/editor-core/src/Services/BaseRegistry.ts
Normal file
301
packages/editor/editor-core/src/Services/BaseRegistry.ts
Normal 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}`);
|
||||
}
|
||||
312
packages/editor/editor-core/src/Services/Build/BuildService.ts
Normal file
312
packages/editor/editor-core/src/Services/Build/BuildService.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
441
packages/editor/editor-core/src/Services/Build/IBuildPipeline.ts
Normal file
441
packages/editor/editor-core/src/Services/Build/IBuildPipeline.ts
Normal 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;
|
||||
}
|
||||
28
packages/editor/editor-core/src/Services/Build/index.ts
Normal file
28
packages/editor/editor-core/src/Services/Build/index.ts
Normal 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';
|
||||
@@ -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) | 加载 WASM(iOS 上使用 WXWebAssembly)
|
||||
const isIOS = wx.getSystemInfoSync().platform === 'ios';
|
||||
if (isIOS) {
|
||||
// iOS uses WXWebAssembly | iOS 使用 WXWebAssembly
|
||||
await ECS.initWasm('./wasm/es_engine_bg.wasm', { useWXWebAssembly: true });
|
||||
} else {
|
||||
await ECS.initWasm('./wasm/es_engine_bg.wasm');
|
||||
}
|
||||
|
||||
// Create runtime | 创建运行时
|
||||
const canvas = wx.createCanvas();
|
||||
const runtime = ECS.createRuntime({
|
||||
canvas: canvas,
|
||||
platform: 'wechat'
|
||||
});
|
||||
|
||||
// Load scene and start | 加载场景并启动
|
||||
await runtime.loadScene('./scenes/main.ecs');
|
||||
runtime.start();
|
||||
|
||||
console.log('[Game] Started successfully | 游戏启动成功');
|
||||
} catch (error) {
|
||||
console.error('[Game] Failed to start | 启动失败:', error);
|
||||
}
|
||||
})();
|
||||
`;
|
||||
|
||||
await fs.writeFile(`${context.outputDir}/game.js`, gameJs);
|
||||
console.log('[WeChatBuild] Generated game.js | 生成 game.js');
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate project.config.json.
|
||||
* 生成 project.config.json。
|
||||
*/
|
||||
private async _generateProjectConfig(context: BuildContext): Promise<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
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Build Pipelines.
|
||||
* 构建管线。
|
||||
*/
|
||||
|
||||
export { WebBuildPipeline, type IBuildFileSystem } from './WebBuildPipeline';
|
||||
export { WeChatBuildPipeline } from './WeChatBuildPipeline';
|
||||
210
packages/editor/editor-core/src/Services/CommandManager.ts
Normal file
210
packages/editor/editor-core/src/Services/CommandManager.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
28
packages/editor/editor-core/src/Services/CompilerRegistry.ts
Normal file
28
packages/editor/editor-core/src/Services/CompilerRegistry.ts
Normal 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');
|
||||
@@ -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');
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
@@ -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');
|
||||
@@ -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) | 预览场景 ID(null = 主场景) */
|
||||
previewSceneId?: string;
|
||||
/** Whether to show grid | 是否显示网格 */
|
||||
showGrid?: boolean;
|
||||
/** Whether to show gizmos | 是否显示辅助线 */
|
||||
showGizmos?: boolean;
|
||||
/** Initial camera configuration | 初始相机配置 */
|
||||
camera?: ViewportCameraConfig;
|
||||
/** Clear color | 清除颜色 */
|
||||
clearColor?: { r: number; g: number; b: number; a: number };
|
||||
}
|
||||
|
||||
/**
|
||||
* Viewport state
|
||||
* 视口状态
|
||||
*/
|
||||
interface ViewportState {
|
||||
config: EditorViewportConfig;
|
||||
camera: ViewportCameraConfig;
|
||||
overlays: Map<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');
|
||||
@@ -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');
|
||||
137
packages/editor/editor-core/src/Services/EntityStoreService.ts
Normal file
137
packages/editor/editor-core/src/Services/EntityStoreService.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
163
packages/editor/editor-core/src/Services/FileActionRegistry.ts
Normal file
163
packages/editor/editor-core/src/Services/FileActionRegistry.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
19
packages/editor/editor-core/src/Services/ICommand.ts
Normal file
19
packages/editor/editor-core/src/Services/ICommand.ts
Normal 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[];
|
||||
}
|
||||
33
packages/editor/editor-core/src/Services/ICompiler.ts
Normal file
33
packages/editor/editor-core/src/Services/ICompiler.ts
Normal 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;
|
||||
}
|
||||
26
packages/editor/editor-core/src/Services/IDialog.ts
Normal file
26
packages/editor/editor-core/src/Services/IDialog.ts
Normal 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');
|
||||
12
packages/editor/editor-core/src/Services/IEditorDataStore.ts
Normal file
12
packages/editor/editor-core/src/Services/IEditorDataStore.ts
Normal 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;
|
||||
}
|
||||
49
packages/editor/editor-core/src/Services/IFieldEditor.ts
Normal file
49
packages/editor/editor-core/src/Services/IFieldEditor.ts
Normal 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;
|
||||
};
|
||||
}
|
||||
30
packages/editor/editor-core/src/Services/IFileSystem.ts
Normal file
30
packages/editor/editor-core/src/Services/IFileSystem.ts
Normal 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');
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
109
packages/editor/editor-core/src/Services/IViewportService.ts
Normal file
109
packages/editor/editor-core/src/Services/IViewportService.ts
Normal 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');
|
||||
@@ -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');
|
||||
367
packages/editor/editor-core/src/Services/LocaleService.ts
Normal file
367
packages/editor/editor-core/src/Services/LocaleService.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
236
packages/editor/editor-core/src/Services/LogService.ts
Normal file
236
packages/editor/editor-core/src/Services/LogService.ts
Normal 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 = [];
|
||||
}
|
||||
}
|
||||
336
packages/editor/editor-core/src/Services/MessageHub.ts
Normal file
336
packages/editor/editor-core/src/Services/MessageHub.ts
Normal 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');
|
||||
@@ -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();
|
||||
115
packages/editor/editor-core/src/Services/Module/ModuleTypes.ts
Normal file
115
packages/editor/editor-core/src/Services/Module/ModuleTypes.ts
Normal 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;
|
||||
}
|
||||
7
packages/editor/editor-core/src/Services/Module/index.ts
Normal file
7
packages/editor/editor-core/src/Services/Module/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Module System exports.
|
||||
* 模块系统导出。
|
||||
*/
|
||||
|
||||
export * from './ModuleTypes';
|
||||
export * from './ModuleRegistry';
|
||||
476
packages/editor/editor-core/src/Services/PrefabService.ts
Normal file
476
packages/editor/editor-core/src/Services/PrefabService.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
272
packages/editor/editor-core/src/Services/PreviewSceneService.ts
Normal file
272
packages/editor/editor-core/src/Services/PreviewSceneService.ts
Normal 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');
|
||||
593
packages/editor/editor-core/src/Services/ProjectService.ts
Normal file
593
packages/editor/editor-core/src/Services/ProjectService.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
69
packages/editor/editor-core/src/Services/PropertyMetadata.ts
Normal file
69
packages/editor/editor-core/src/Services/PropertyMetadata.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
887
packages/editor/editor-core/src/Services/SceneManagerService.ts
Normal file
887
packages/editor/editor-core/src/Services/SceneManagerService.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
184
packages/editor/editor-core/src/Services/SerializerRegistry.ts
Normal file
184
packages/editor/editor-core/src/Services/SerializerRegistry.ts
Normal 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');
|
||||
262
packages/editor/editor-core/src/Services/SettingsRegistry.ts
Normal file
262
packages/editor/editor-core/src/Services/SettingsRegistry.ts
Normal 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');
|
||||
226
packages/editor/editor-core/src/Services/UIRegistry.ts
Normal file
226
packages/editor/editor-core/src/Services/UIRegistry.ts
Normal 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');
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
1398
packages/editor/editor-core/src/Services/UserCode/UserCodeService.ts
Normal file
1398
packages/editor/editor-core/src/Services/UserCode/UserCodeService.ts
Normal file
File diff suppressed because it is too large
Load Diff
131
packages/editor/editor-core/src/Services/UserCode/index.ts
Normal file
131
packages/editor/editor-core/src/Services/UserCode/index.ts
Normal 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';
|
||||
325
packages/editor/editor-core/src/Services/VirtualNodeRegistry.ts
Normal file
325
packages/editor/editor-core/src/Services/VirtualNodeRegistry.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
162
packages/editor/editor-core/src/Services/WindowRegistry.ts
Normal file
162
packages/editor/editor-core/src/Services/WindowRegistry.ts
Normal 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');
|
||||
73
packages/editor/editor-core/src/Types/IFileAPI.ts
Normal file
73
packages/editor/editor-core/src/Types/IFileAPI.ts
Normal 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>;
|
||||
}
|
||||
106
packages/editor/editor-core/src/Types/UITypes.ts
Normal file
106
packages/editor/editor-core/src/Types/UITypes.ts
Normal 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';
|
||||
49
packages/editor/editor-core/src/global.d.ts
vendored
Normal file
49
packages/editor/editor-core/src/global.d.ts
vendored
Normal 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 {};
|
||||
110
packages/editor/editor-core/src/index.ts
Normal file
110
packages/editor/editor-core/src/index.ts
Normal 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';
|
||||
239
packages/editor/editor-core/src/tokens.ts
Normal file
239
packages/editor/editor-core/src/tokens.ts
Normal 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';
|
||||
Reference in New Issue
Block a user